稻草问答-从单体应用到微服务
稻草问答-从单体应用到微服务
1 单体应用与微服务
1.1 为何需要将单体应用迁移为微服务架构
目前项目straw-portal是单体应用程序,一个应用包含全部的功能,功能没有问题,但是在上线以后一般部署到一个服务器,单台服务器性能一定有上限的,无法应付高并发,做到高性能,现在解决高性能到通行办法就是利用微服务,将应用功能分散到多个服务器上,让多个服务器一同提供服务,这样就可以分散计算量由多个服务器共同计算,提升系统到整体性能。
我们下面就将单体应用straw-portal进行拆分,从功能上拆分为如下几个为服务:
- straw-sys系统基础服务:负责用户管理等系统基础服务;
- straw-resource静态资源服务:负责静态图片资源的上载、下载;
- straw-search搜索服务:负责系统中问答的搜索服务;
- straw-gateway网关:负责系统UI界面和系统安全服务。
服务拆分以后,系统功能就可以由多个模块承担,如果将这些模块部署在多个服务器上,就可以将软件的计算由多个服务器承担,使软件的整体计算性能大大提升。这种将软件模块部署在多个服务器的系统就称为分布式系统。
1.2 将UI界面迁移到网关
首先将界面组件 static、template 从straw-portal复制到straw-gateway对应的位置上:
复制UI界面以后先启动straw-eureka然后启动straw-gateway:
如果在straw-gateway的static中存在静态网页如vue.html,则使用浏览器访问http://localhost:9000/vue.html 就可以得到:Hello World!
这个结果说明,静态页面迁移成功了。
还需要能够处理动态模板,首先将Thymeleaf的依赖添加到straw-gateway到pom文件中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
需要利用控制器转发到模板才能生效,所以在straw-gateway项目 中创建controller包,在包创建HomeCotoller类:
@Controller
@Slf4j
public class HomeController {
@GetMapping("register.html")
public String register(){
log.debug("转发到register");
return "register";
}
}
@Controller 也是声明控制器。
@Controller 控制器方法返回String时候是视图名称,与new ModelAndView(视图名称)作
用一样,是一种简化版本写法。
@RestController 控制器所有方法相当添加了@ResponseBody,也就是方法默认情况下返
回的JavaBean自动转换为Json对象。
然后重新启动项目,然后利用浏览器访问控制器的地址: http://localhost:9000/register.html这样就能看到模板页面了
2 迁移"学生注册功能"
2.1 创建微服务模块项目straw-sys
用户,权限相关的功能我们划分为系统服务微服务模块,注册功能就属于这个模块下的功能,我们迁移的思路是创建一个新的模块项目straw-sys,然后将注册功能迁移好,再利用zul网关的路由功能,整合到网关上。
首先创建straw-sys模块项目:
然后修改straw-sys的pom文件,使其继承于straw项目:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.tedu</groupId>
<artifactId>straw</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>straw-sys</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>straw-sys</name>
<description>系统模块:包含用户管理,用户权限功能</description>
<dependencies>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
在straw项目pom文件中配置模块:
<modules>
<module>straw-sys</module>
</modules>
2.2 将服务模块注册到Eureka
将straw-sys项目注册到Eureka注册中心,首先修改straw-sys的pom导入eureka-client:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
然后配置straw-sys的applicationproperties:
server.port=8002
spring.application.name=sys-service
在启动类上添加注解开启Eureka注册:
@SpringBootApplication
@EnableEurekaClient
public class StrawSysApplication {
public static void main(String[] args) {
SpringApplication.run(StrawSysApplication.class, args);
}
}
先启动straw-eureka,再启动straw-sys,检查straw-sys服务 模块是否注册到Eureka.
2.3 在网关上添加straw-sys模块路由配置
在网关上添加路由配置,将straw-sys服务模块与网关整合为一个整体。
在straw-sys模块上创建Controller包,编写测试控制器类:
@RestController
@Slf4j
@RequestMapping("/v1/sys")
public class DemoController {
@GetMapping("/demo")
public String demo(){
log.debug("Hello sys");
return "Hello sys";
}
}
启动项目后利用浏览器测试http://localhost:8002/v1/sys/demo
在网关straw-gateway的配置文件中添加路由配置,将/sys/** 请求转发到sys-service,其中sys-service是eureka中登记的注册名称:
##配置路由信息,访问/sys/**时候就路由到sys-service
##sys-service 是在Eureka中注册的ID
zuul.routes.sys.path=/sys/**
zuul.routes.sys.service-id=sys-service
重新启动straw-eureka、straw-gateway. straw-sys, 然后用浏览器访问网关路径http://localhost:9000/sys/v1/sys/demo。检查是否成功访问了控制器。
2.4 抽取straw-commons模块
首先将每个子模块都需要使用的类抽取到straw-commons模块,这样就方便代码的复用
首先创建straw-commons模块项目:
然后配置straw-commons的pom.xml文件,设置从straw项目继承:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.tedu</groupId>
<artifactId>straw</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>straw-commons</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>straw-commons</name>
<description>通用模块</description>
</project>
在父项目straw的pom文件中登记模块:
<modules>
<module>straw-commons</module>
</modules>
在straw项目的pom文件的< dependencyManagement>中添加straw-commons项目为依赖,这样就可以统一管理straw-commons组件了:
<properties>
<straw-commons.version>0.0.1-SNAPSHOT</straw-commons.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>cn.tedu</groupId>
<artifactId>straw-commons</artifactId>
<version>${straw-commons.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
将straw-portal项目中的model包全部复制到straw-commons 项目中,在straw-commons项目中创建vo包、service包,将straw-portal的R类复制到vo包,将straw-portal的ServiceException类复制到service包:
复制后,straw-commons项目会出现编译错误,原因是model包中的类依赖MyBatis-Plus的注解,此时将MyBatis-Plus添加到straw-commons的pom文件即可:
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
然后在straw-sys的pom文件中添加straw-commons组件:
<dependency>
<groupId>cn.tedu</groupId>
<artifactId>straw-commons</artifactId>
</dependency>
此时在straw-sys项目中就可以使用来自straw-commons中的类了。
2.5 将注册功能迁移到straw-sys
注册功能包含4个部分:界面、控制器、业务层、数据层。其中界面已经迁移到straw-gateway,并且可以在浏览器中显示。下面需要迁移其它几个部分:
- 迁移数据层到straw-sys服务模块;
- 迁移业务层到straw-sys服务模块;
- 迁移控制器到straw-sys服务模块;
- 配置网关路由,将请求转发到straw-sys模块
首先迁移数据层到straw-sys服务模块,将与用户有关的Mapper接口复制到straw-sys项目中:
迁移后会出现编译错误,需要在straw-sys的pom文件中添加验证框架和MyBatisPlus的包,添加以后需要点击Maven标签中的Reimport按钮:
<!--验证框架-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<!-- mysqlJDBC -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
在SpringBoot应用启动类中添加MyBatis的Mapper接口扫描:
@SpringBootApplication
@EnableEurekaClient
@MapperScan("cn.tedu.straw.sys.mapper")
public class StrawSysApplication {
public static void main(String[] args) {
SpringApplication.run(StrawSysApplication.class, args);
}
}
修改application.properties添加MyBatis配置信息:
logging.level.cn.tedu.straw.sys=debug
## Mysql数据库连接参数
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/straw?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=990921
编写测试案例,测试是否成功创建了Mapper接口实例:
package cn.tedu.straw.sys;
import cn.tedu.straw.sys.mapper.ClassroomMapper;
import cn.tedu.straw.sys.mapper.UserMapper;
import cn.tedu.straw.sys.mapper.UserRoleMapper;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
@SpringBootTest
@Slf4j
public class MapperTests {
@Resource
ClassroomMapper classroomMapper;
@Resource
UserMapper userMapper;
@Resource
UserRoleMapper userRoleMapper;
@Test
public void mappers(){
log.debug("{}",classroomMapper);
log.debug("{}",userMapper);
log.debug("{}",userRoleMapper);
}
}
测试Mapper......
然后迁移业务层,将用户有关的业务层复制到straw-sys项目的对应包中:
迁移以后会出现编译错误,为了解决编译错误,先删除两个业务层方法getUserDetails()和currentUsername():
也删除对应的实现类UserServicelmpl中的方法,并且先删除question有关的API,question信息会在后续的重构中利用Ribbon技术解决。
package cn.tedu.straw.sys.service.impl;
import cn.tedu.straw.commons.model.*;
import cn.tedu.straw.sys.mapper.ClassroomMapper;
import cn.tedu.straw.sys.mapper.UserMapper;
import cn.tedu.straw.sys.mapper.UserRoleMapper;
import cn.tedu.straw.sys.service.IUserService;
import cn.tedu.straw.commons.service.ServiceException;
import cn.tedu.straw.sys.vo.RegisterVo;
import cn.tedu.straw.sys.vo.UserVo;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* <p>
* 服务实现类
* </p>
*
* @author tedu.cn
* @since 2021-07-29
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements IUserService {
@Autowired
UserMapper userMapper;
@Autowired
ClassroomMapper classroomMapper;
@Autowired
UserRoleMapper userRoleMapper;
//密码加密
BCryptPasswordEncoder passwordEncoder=new BCryptPasswordEncoder();
private final List<User> masters=new CopyOnWriteArrayList<>();
private final Map<String , User> masterMap=new ConcurrentHashMap<>();
private final Timer timer=new Timer();
{//代码块在创建对象时候执行
//每个小时执行一次,清除缓存,实现缓存过期功能
timer.schedule(new TimerTask() {
@Override
public void run() {
synchronized (masters) {
masters.clear();
masterMap.clear();
}
}
} , 1000*60*60, 1000*60*60);
}
@Override
public void registerStudent(RegisterVo registerVo) {
//为了让方法更加健壮,在方法开始都会检测方法的参数
//如果不检查参数,可能会发生空指针异常等问题
if(registerVo==null){
log.info("方法参数为null");
throw ServiceException.unprocesabelEntiry("参数为空!");
}
log.debug("方法参数{}",registerVo);
log.debug("验证邀请码{}",registerVo.getInviteCode());
QueryWrapper<Classroom> query=new QueryWrapper<>();
query.eq("invite_code",registerVo.getInviteCode());
Classroom classroom=classroomMapper.selectOne(query);
if(classroom==null){
log.info("验证邀请码失败!");
throw ServiceException.notFound("无效的邀请码,请联系任课老师!");
}
log.debug("验证手机号码是否使用{}",registerVo.getPhone());
User user=userMapper.findUserByUsername(registerVo.getPhone());
if(user!=null){
log.info("手机号已经注册过!");
throw ServiceException.unprocesabelEntiry("手机号码已经注册啦!");
}
user=new User();
user.setUsername(registerVo.getPhone());
user.setNickname(registerVo.getNickname());
String password=passwordEncoder.encode(registerVo.getPassword());
user.setPassword("{bcrypt}"+passwordEncoder.encode(registerVo.getPassword()));
user.setLocked(0);
user.setEnabled(1);
user.setCreatetime(LocalDateTime.now());
user.setClassroomId(classroom.getId());
user.setBirthday(null);
int rows=userMapper.insert(user);
if(rows!=1){
log.info("保存用户信息失败");
throw new ServiceException("数据库繁忙,请稍后再试!");
}
log.debug("保存User数据成功{}",user);
log.debug("设置用户是一个学生角色");
//Role 的ID 是2
UserRole userRole=new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(2);
rows=userRoleMapper.insert(userRole);
if(rows!=1){
log.info("保存用户角色信息失败");
throw new ServiceException("数据库繁忙,请稍后再试!");
}
log.debug("设置用户的角色{}",userRole);
}
@Override
public List<User> getMasters() {
if (masters.isEmpty()) {
synchronized (masters) {
if(masters.isEmpty()) {
/**
* user表中type属性是1的都是回答问题的老师
*/
QueryWrapper<User> queryWrapper =
new QueryWrapper<>();
queryWrapper.eq("type", 1);//type是0是学生,type是1是老师
List<User> list = userMapper.selectList(queryWrapper);//自动生成SQL去数据库查询
//初始化 masters 缓存
masters.addAll(list);
//初始化 masterMap 缓存
masters.forEach(master->masterMap.put(master.getNickname(), master));
//清除敏感信息:密码
masters.forEach(master -> master.setPassword(""));
}
}
}
return masters;
}
@Override
public Map<String, User> getMasterMap() {
if(masterMap.isEmpty()){
getMasters();
}
return masterMap;
}
@Override
public UserVo getCurrentUserVo(String username) {
//获取用户
//String username=currentUsername();
//用户信息
UserVo userVo=userMapper.getUserVoByUsername(username);
//统计数量
//Integer questions=questionService.countQuestionsByUserId(userVo.getId());
String url="http://faq-service/v1/questions/count?userId={1}";
Integer questions=restTemplate.getForObject(url, Integer.class, userVo.getId());
//TODD:以后增加统计收藏的问题数量
userVo.setQuestions(questions).setCollections(0);
return userVo;
}
}
上述实现类中因为引用了Spring-Security组件,所以需要添加Spring-Security依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
安全功能由网关straw-gateway统一解决,所以straw-sys模块不用管理安全。在straw-sys模块security包中添加Spring-Securty配置类,在类中不放过全部的请求,不进行安全现限制:
package cn.tedu.straw.sys.security;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
//放过全部的请求,安全功能有straw-gateway复制
http.csrf().disable().authorizeRequests().anyRequest().permitAll();
}
}
配置完成以后,可以测试一下是否可以创建业务层对象:
@SpringBootTest
@Slf4j
public class ServicesTests {
@Resource
IUserService userService;
@Test
void services(){
log.debug("{}",userService);
}
}
在straw-sys中添加controller包,然后添加异常处理类:
@RestControllerAdvice
@Slf4j
/**
* 统一异常处理类
*/
public class ExceptionControllerAdvice {
/**
* ServiceException 处理方法
* @param e
*/
@ExceptionHandler
public R handleServiceException(ServiceException e){
log.debug("处理业务异常");
log.error("业务异常",e);
return R.failed(e);
}
/**
* 处理其他异常
*/
@ExceptionHandler
public R handleException(Exception e){
log.error("其他异常!",e);
return R.failed(e);
}
}
在straw-sys中添加控制器类UserContoller,处理注册功能,控制器中的方法来自straw-portal的SystemCotrller:
@RestController
@RequestMapping("/v1/users")
@Slf4j
public class UserController {
@Resource
private IUserService userService;
/**
* @Validated 开启对RegisterVo 对象数据验证,验证其中的注解
* 验证结果会自动存储到BindingResult对象中
* validaResult.hasErrors()用于检测是否有错误
* validaResult.getFieldError().getDefaultMessage()方法可以获取
* 验证期间出现的错误信息
*/
@PostMapping("/register")
public R registerStudent(@Validated RegisterVo registerVo,
BindingResult validaResult){
log.debug("收到表单数据{}",registerVo);
//检测验证结果是否有错误
if(validaResult.hasErrors()){
//取得验证错误
String error=validaResult.getFieldError().getDefaultMessage();
log.info("表单验证错误{}",error);
return R.unprocesabelEntity(error);
}
if(! registerVo.getPassword().equals(registerVo.getConfirm())){
log.info("确认密码不一致!");
return R.unprocesabelEntity("确认密码不一致!");
}
userService.registerStudent(registerVo);
return R.created("成功注册!");
}
}
重构straw-gateway模块的static文件夹中的注册脚本,将注册请求提交到通过网关映射的控制器url: /sys/v1/users/register.
let app = new Vue({
el:'#app',
data:{
inviteCode:'',
phone:'',
nickname:'',
password:'',
confirm:'',
message: '',
hasError: false
},
methods:{
register:function () {
console.log('Submit');
let data = {
inviteCode: this.inviteCode,
phone: this.phone,
nickname: this.nickname,
password: this.password,
confirm: this.confirm
}
console.log(data);
if(data.password !== data.confirm){
this.message = "确认密码不一致!";
this.hasError = true;
return;
}
let _this = this;
$.ajax({
url:"/sys/v1/users/register",
method: "POST",
data: data,
success: function (r) {
console.log(r);
if(r.code == CREATED){
console.log("注册成功");
console.log(r.message);
_this.hasError = false;
location.href = '/login.html?register';
}else{
console.log(r.message);
_this.hasError = true;
_this.message = r.message;
}
}
});
}
}
});
测试注册功能......
3 Ribbon负载均衡与用户登录
3.1 什么是Ribbon
Ribbon是Netflix开源的实现了负载均衡等功能的RPC客户端。Spring Cloud基于Ribbon封装了Spring Cloud Ribbon,Ribbon的主要作用是:从注册服务器端拿到对应服务列表后以负载均衡的方式访问对应服务。
何时使用Ribbon呢? 一般情况下当一个模块需要从另外一个模块获取数据时候就需要使用Ribbon的远程访问功能了。比如权限管理功能,目前项目中已经将业务拆分为多个服务:
- 用户管理功能由straw-sys服务模块提供
- 登录权限管理功能由straw-gateway模块提供
straw-gateway模块需要用户数据时候就需要从用户管理模块straw-sys获得,这种问题就可以使用Ribbon进行访问获得:
当微服务项目已经添加了eureka 相关的依赖后,就可以直接使用Ribbon了,不需要额外添加新的依赖。
RestTemplate封装了Ribbon调用的过程,其中getForObject是最常用方法:
-
第一个参数url表示被调用的目标Rest接口位置:
- urI的第一部分是在Eureka中注册的服务提供者名称,如果多个服务提供者注册相同名称,Ribbon会自动寻找其中一个服务提供者,并且调用接口方法。这个就是负载均衡功能
- url后半部是控制器的请求路径
-
第二个参数是返回值类型,JavaBean类型或者JavaBean数组类型,如果控制器返回的是List集合,需要使用数组类型接收。
-
第三个参数是可变参数,是传递给url的动态参数,使用参数时候需要在url上需要使用{1]、(2]、 {3}进行参数占位,这样传递的参数就会自动替换占位符。
为了测试Ribbon我们首先服务提供者必须提供相关的Rest调用接口,在straw-sys服务模块中添加Rest接口控制器:
@RestController
@Slf4j
@RequestMapping("/v1/auth")
public class AuthController {
@GetMapping("/demo")
public String demo(){
log.debug("Call demo()");
return "Hello World";
}
}
然后在straw-gateway模块的启动类中配置RestTemplate, RestTemplate是SpringCloud提供的Ribbon客户端,利用RestTemplate就可以作为服务的消费者调用straw-sys声明的Rest接口方法:
@SpringBootApplication
@EnableZuulProxy
public class StrawGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(StrawGatewayApplication.class, args);
}
/**
* 创建RestTemplate,封装了Ribbon的客户端
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
在straw-gateway项目中声明测试案例测试是否可以进行Ribbon远程调用:
package cn.tedu.straw.gateway;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
@SpringBootTest
@Slf4j
public class RibbonTests {
@Resource
RestTemplate restTemplate;
@Test
void demo(){
log.debug("Start Ribbon Test");
String url="http://sys-service/v1/auth/demo";
String str=restTemplate.getForObject(url, String.class);
log.debug("Ribbon Result:{}",str);
}
}
Ribbon也可以实现负载均衡功能,当多个微服务都使用一个“注册名”注册时候,Ribbon就会在多个微服务中选择一个服务调用其Rest控制器.
3.2 迁移“登录”功能
迁移“登录”功能步骤:
- 在straw-sys微服务项目业务层UserSerice添加获取用户和权限信息的方法;
- 在straw-sys的AuthController控制器中定义Rest接口,作为用户和权限信息的提供者;
- 在straw-gateway中添加Spring-Security依赖; .
- 添加UserDetailsServicelmpl类, 提供认证和授权信息。在这个类中利用Ribbon从straw-sys服务模块中获取认证授权信息;
- 添加SpringSecurity配置类,配置访问规则。
首先是在straw-sys微服务项目业务层lUserService添加获取用户和权限信息的方法:
//根据用户名获取用户信息
User getUserByUsername(String username);
//根据用户id获取用户的全部权限信息
List<Role> getUserRoles(Integer userId);
//根据用户id获取用户的全部角色信息
List<Permission> getUserPermissions(Integer id);
在UserServicelmpl类中实现方法:
@Override
public User getUserByUsername(String username) {
return userMapper.findUserByUsername(username);
}
@Override
public List<Permission> getUserPermissions(Integer userId) {
return userMapper.findUserPermissionById(userId);
}
@Override
public List<Role> getUserRoles(Integer userId) {
return userMapper.findUserRolesById(userId);
}
利用测试案例进行测试:
@Test
void userInfo(){
User user=userService.getUserByUsername("wangkj");
log.debug("user:{}",user);
List<Permission> permissions=
userService.getUserPermissions(user.getId());
List<Role> roles=userService.getUserRoles(user.getId());
permissions.forEach(permission -> log.debug("{}",permission));
roles.forEach(role -> log.debug("{}",role));
}
在控制器中定义Rest接口方法:
@RestController
@Slf4j
@RequestMapping("/v1/auth")
public class AuthController {
@Resource
private IUserService userService;
@GetMapping("/demo")
public String demo(){
log.debug("Call demo()");
return "Hello World";
}
@GetMapping("/user")
public User getUser(String username){
return userService.getUserByUsername(username);
}
@GetMapping("/permissions")
public List<Permission> getPermissions(Integer userId){
return userService.getUserPermissions(userId);
}
@GetMapping("/roles")
public List<Role> getRoles(Integer userId){
return userService.getUserRoles(userId);
}
}
在straw-gateway中添加Spring-Security依赖,并且重新导入包:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
添加UserDetailsServiceImpI类,通过授权和认证信息,在这个类中利用Ribbon从straw-sys服务模块中获取认证授权信息:
package cn.tedu.straw.gateway.security;
@Component
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private RestTemplate restTemplate;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
String url="http://sys-service/v1/auth/user?username={1}";
User user=restTemplate.getForObject(url, User.class,username);
if(user==null){
throw new UsernameNotFoundException("您是否忘记了用户名或密码!");
}
//获取用户的全部权限
url="http://sys-service/v1/auth/permissions?userId={1}";
Permission[] permissions=restTemplate.getForObject(url, Permission[].class, user.getId());
//查询全部的角色
url="http://sys-service/v1/auth/roles?userId={1}";
Role[] roles=restTemplate.getForObject(url,Role[].class,user.getId());
String[] authorities=new String[permissions.length + roles.length];
int index=0;
for(Permission permission:permissions){
authorities[index++]=permission.getName();
}
for(Role role:roles){
authorities[index++]=role.getName();
}
UserDetails u=org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.accountLocked(user.getLocked().equals(1))//账号锁定状态
.disabled(user.getEnabled().equals(0))
.build();
log.debug("UserDetails:{}",u);
return u;
}
}
{1} {2}...是Ribbon调用时候的参数占位符,执行方法时候应该传递对应的参数。返回值时候是List集合时候需要按照数组类型进行接收。
添加SpringSecurity配置类,配置访问规则:
package cn.tedu.straw.gateway.security;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启注解权限管理
public class SecurityConfig extends WebSecurityConfigurerAdapter {
//@Bean
//public PasswordEncoder passwordEncoder(){
// return new BCryptPasswordEncoder();
// }
@Autowired
UserDetailsServiceImpl userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
/*
添加了一个测试用户:Tom 密码:990921 权限:/user/get
*/
//auth.inMemoryAuthentication().withUser("Tom")
//.password("{bcrypt}$2a$10$ca8jmFR7X2LJ/lcGpF1NhOrSKtCPzKADAcq.5ipgiecGhW5JOztQq")
//.authorities("/user/get","/user/list");
auth.userDetailsService(userDetailsService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests().antMatchers(
"/js/*",
"/css/*",
"/bower_components/**",
"/img/*",
"/login.html",
"/register.html",
"/register"
).permitAll().anyRequest().authenticated()
.and().formLogin().loginPage("/login.html")
.loginProcessingUrl("/login")
.failureUrl("/login.html?error")
.defaultSuccessUrl("/index.html")
.and().logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login.html?logout");
}
}
测试登录功能。
先显示一下首页。
上述登录功能迁移成功以后,自然需要迁移首页功能,首页是一个动态模板页面,在straw-gateway的HomeController中添加一个转发就可以显示首页信息了。
根据用户的角色显示不同的页面:
@Controller
@Slf4j
public class HomeController {
static final GrantedAuthority STUDENT=new SimpleGrantedAuthority("ROLE_STUDENT");
static final GrantedAuthority TEACHER=new SimpleGrantedAuthority("ROLE_TEACHER");
@GetMapping("register.html")
public String register(){
log.debug("转发到register");
return "register";
}
@GetMapping("login.html")
public String login(){
return "login";
}
@GetMapping("/index.html")
public String index(@AuthenticationPrincipal User user){
if(user.getAuthorities().contains(STUDENT)){
//如果当前登录用户的角色是学生,就转到学生首页
return "index";
//如果当前登录用户是老师,就转到老师首页
}else if(user.getAuthorities().contains(TEACHER)){
return "index_teacher";
}
throw new ServiceException("需要登录");
}
}
由于straw-commons项目中依赖了mybatis-plus-boot-starter,子项目依赖straw-commons后,就自动继承了这个依赖,造成必须配置数据库连接,否则程序启动就会出现运行错误。解决这个问题的办法就是,重构straw-commons项目,使其依赖MyBatisPlus,不依赖mybatis-plus-boot-start。
具体步骤是在父项目straw的pom文件配置依赖管理mybatis-plus
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
然后在子项目straw-commons的pom文件中修改依赖,从mybatis-plus-boot-starter修改为mybatis -plus:
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus</artifactId>
</dependency>
</dependencies>
更新Maven组件以后,重新启动所有微服务,进行测试...
4 Session共享和Redis
4.1 为何需要Session共享
上述页面模板虽然能够显示,但是模板上的标签、问题列表、当前用户信息都无法显示,原因是,对应的控制器方法还没有迁移。迁移这部分功能时候就需要进行Session共享。
其原因在于:( straw-gateway 项目是通过SpringSecurity实现身份验证的,而SpringSecurity默认使用Session机制来保存登录用户的信息,而Session是保存在服务器计算机的内存中的,而straw-gateway 和straw-sys 这2个项目是运行在不同的服务器上的,则无法共享同一份Session数据,用户在straw-gateway 上登录后,在straw-sys上并没有该用户的Session信息,所以,依然视为“未经过登录授权”的状态!
而因为显示当前用户的问题列表,显示当前用户信息,都需要用到当前用户状态,而这些Session状态默认保存在straw-gateway项目中,如果不共享到straw-sys模块就无法根据当前用户查询相关信息了。
SpringCloud提供了Session共享功能:也就是straw-gateway登录一次,就可以把Session共享给其它的服务组件,一次登录后Session到处可用:
-
安装部署Redis数据库
-
在straw-gateway项目开启Session共享
- 导入Redis客户端和Session共享包
- 开启session共享注解@EnableRedisHttpSession
-
在网关中配置路由转发"敏感头"信息
-
在自模块项目中开启Session共享
- 导入Redis客户端和Sesison共享包
- 开启session共享注解@EnableRedisHttpSession
4.2 搭建Redis服务器
Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久性的键值对存储数据库。目前Redis的开发由Redis Labs赞助。根据月度排行网站DB-Engines com的数据,Redis是最流行的键值对存储数据库。
Redis本质上是一个Key-Value类型的内存数据库,很像memcached, 整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据flush到硬盘上进行保存。因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过10万次读写操作,是已知性能最快的Key-Value DB。在软件中经常作为缓存使用。
Redis与其他key一value缓存产品有以下三个特点:
- Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,这样可以解决缓存冷启动预热问题。
- Redis不仅仅支持简单的key-value类型的数据,同时还提供list, set, zset, hash等数据结构的存储。适合多种应用场景。
- Redis支持数据的备份,即master-slave模式的数据备份。可以利用分片技术实现集群部著,提高并发性能和可用性。
安装Redis
Redis采用标准C开发,可以在任何平台上部署。
首先下载Redis
然后利用解压缩工具,将压缩包释放到一个文件夹
这样Redis就安装好了。
使用Redis首先需要启动Redis服务器,进入redis文件中, 双击执行redis-start.bat就执行Redis服务器程序,启动Redis服务器。 第一次启动服务器往往会出现防火墙警告,此时请务必选择“允许访问”,否则防火墙将阻止Redis的网络访问造成无法使用的故障。
Redis启动以后会占用一个命令窗口,请不要关闭这个窗口, 关闭这个窗口就相当于关闭了Redis服务器!这种关闭方便并不安全!
也可以使用命令将Redis安装为Windows的服务,在后台启动:
- service- installing.bat 安装Redis服务
- service-uninstalling.bat 删除Redis服务
- service-start.bat 启动Redis服务
- service-stop.bat 停止Redis服务
在Redis窗口中可以看到用字符拼接的Redis图标,以及Redis服务器的版本和默认端口号: 6379。
再启动Redis客户端程序redis-cli.exe验证服务器工作是否正常
在客户端中输入命令info,然后按下回车,此时会显示一个Redis服务器的详细信息,出现这个就表示一切平安! Redis服务器正确。如果在客户端窗口中输入: shutdown命令就可以正确关闭Redis服务器了!
4.3 常用命令
Redis是Key-Value数据库,Redis客户端命令都是数据存储操作命令,这些命令能够完成数据的CRUD操作,要想理解Redis操作命令就必须先了解Redis的五种数据类型:
Redis数据类型分为:字符串类型、散列类型、列表类型、集合类型、有序集合类型。命令也按照类型分为5大类+通用命令。
注意:
- key不要太长,尽量不要超过1024字节,这不仅消耗内存,而且会降低查找的效率;
- key也不要太短,太短的话,key的可读性会降低;
- 在一个项目中,key最好使用统一的命名模式,例如user: 10000:passwd。
String 字符串
常用命令: set,get,decr,incr,mget等。
String数据结构是简单的key-value类型,value其实不仅可以是String,,也可以是数字。常规key-value缓存应用;常规计数: 微博数,粉丝数等。因为是二进制安全的,所以完全可以把一个图片文件的内容作为字符串来存储。
最简单的例子:
127.0.0.1:6379> set mystr "hello world!" //设置字符串类型
127.0.0.1:6379> get mystr //读取字符串类型
在遇到数值操作时,redis会将字符串类型转换成数值,如下是数值操作的例子:
127.0.0.1:6379> set mynum "2" //创建数字
OK
127.0.0.1:6379> get mynum//查询数字
"2"
127.0.0.1:6379> incr mynum//数字增加
(integer)3
127.0.0.1:6379> get mynum//查询数字
"3"
127.0.0.1:6379> decr mynum1//数字减少
(integer)2
127.0.0.1:6379> get mynum//查询数字
"2"
由于INCR等指令本身就具有原子操作(线程安全)的特性,所以我们完全可以利用redis的INCR、INCRBY、 DECR、DECRBY等指令来实现原子计数的效果,假如,在某种场景下有3个客户端同时读取了mynum的值(值为2 ) , 然后对其同时进行了加1的操作,那么,最后mynum的值一定是5。 不少网站都利用redis的这个特性来实现业务上的统计计数需求。
4.4 SpringBoot整合Redis
Redis提供了Jedis API.可以在Java中访问Redis数据库,这种访问方式是原生的访问Redis,使用起来比较繁琐。Spring还提供了进一步的封装,使Java访问Redis更加简便。使用方式如下:
- 导入SpringBoot依赖
- 在项目中利用Redis Template访问Redis
- 在straw-gateway项目中导入Redis包:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
然在straw-gateway项目application.properties中添加Redis连接参数:
spring.redis.host=localhost
spring.redis.port=6379
Spring提供了RedisTemplate,这个类封装了Redis API,调用Redis Template就可以访问Redis。
package cn.tedu.straw.gateway;
@SpringBootTest
@Slf4j
public class RedisTests {
@Resource
RedisTemplate<String, String> redisTemplate;
@Test
void hello(){
//将数据通过set命令保存到Redis
redisTemplate.opsForValue().set("msg","Hello World");
//调用get方法从Redis查询一个数据
String str=redisTemplate.opsForValue().get("msg");
log.debug("{}",str);
//调用delete方法,将数据从数据库中删除
redisTemplate.delete("msg");
}
}
4.5 共享Session
SpringBoot和支持Session共享。简单配置就可以:
在网关项目straw-gateway上允许将Session存储到Redis数据库中:
package cn.tedu.straw.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.context.annotation.Bean;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication
@EnableZuulProxy
@EnableRedisHttpSession //允许将Session存储到Redis数据库中
public class StrawGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(StrawGatewayApplication.class, args);
}
/**
* 创建RestTemplate,封装了Ribbon的客户端
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
然后在网关straw-gateway中application.properties的路由上进行配置,允许路由传递“敏感头”,也就是在进行http消息转发时候戴上敏感的与登录认证有关的信息(Cookies等),还有配置将Session保存到Redis中:
##允许在路由时候传递认证敏感信息
zuul.routes.sys.sensitive-headers=Authorization
##将Session存储到Redis
spring.session.store-type=redis
服务模块项目straw-sys中也导入Redis API:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
更新straw-sys项目的application.properties添加Redis连接参数,添加Session存储方式
spring.session.store-type=redis
spring.redis.port=6379
spring.redis.host=localhost
在straw-sys的启动类中添加“允许将Session存储到Redis数据库中”:
package cn.tedu.straw.sys;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.context.annotation.Bean;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.web.client.RestTemplate;
@SpringBootApplication
@EnableEurekaClient
@MapperScan("cn.tedu.straw.sys.mapper")
@EnableRedisHttpSession
public class StrawSysApplication {
public static void main(String[] args) {
SpringApplication.run(StrawSysApplication.class, args);
}
@Bean
@LoadBalanced
public RestTemplate restTemplate(){
return new RestTemplate();
}
}
经过上述配置Spring就会将认证信息存储到Session中,微服务模块就可以利用Redis共享Session.
测试,在straw-sys控制器中获取登录用户信息,这个用户信息本质上是通过Session共享到登录用户信息:
@RestController
@Slf4j
@RequestMapping("/v1/sys")
public class DemoController {
@GetMapping("/demo")
public String demo(){
log.debug("Hello sys");
return "Hello sys";
}
@GetMapping("/me")
public String me(@AuthenticationPrincipal UserDetails userDetails){
log.debug("{}",userDetails);
return userDetails.getUsername();
}
}
4.6 显示当前用户信息
在straw-sys的lUserService中添加业务方法:
/**
* 获取当前用户信息,用于在页面上显示当前用户信息
* 信息包括 用户发布过的问题数量,用户收藏的问题数量
* @return 当前用户信息
*/
UserVo getCurrentUserVo(String username);
在实现类UserServicelmpl中实现这个方法,方法中统计问题数量功能先忽略,留在后续课程实现:
@Override
public UserVo getCurrentUserVo(String username) {
UserVo userVo=userMapper.getUserVoByUsername(username);
return userVo;
}
在straw-sys中UserContollr类添加控制器方法:
/**
* 请求URL /sys/v1/users/me
* @param userDetails
* @return
*/
@GetMapping("/me")
public R<UserVo> me(@AuthenticationPrincipal UserDetails userDetails){
String username=userDetails.getUsername();
UserVo userVo=userService.getCurrentUserVo(username);
return R.ok(userVo);
}
重构straw-gateway中resource/static/js/user_info.js,更新URL为/sys/v1/users/me :
/**
* 获取问题
*/
let userApp=new Vue({
el:'#userApp',
data:{
user:{}
},
methods:{
loadCurrentUser:function () {
$.ajax({
url:'/sys/v1/users/me',
method:'GET',
success:function (r) {
console.log(r);
if(r.code===OK){
userApp.user=r.data;
}else{
alert(r.message);
}
}
});
}
},
created:function () {
this.loadCurrentUser();
}
});
测试......
附录:Redis常用命令:
List列表
Redis的list在底层实现上并不是数组而是链表,Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,消息列表等功能都可以用Redis的list结构来实现。
Redis list 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
另外可以通过lrange 命令,就是从某个元素开始读取多少个元素,可以基于list实现分页查询,这个很棒的一个功能,基于redis实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。
lists的常用操作包括LPUSH、RPUSH、 LRANGE、RPOP等。可以用LPUSH在lists的左侧插入一个新元素,用RPUSH在lists的右侧插入一个新元素 ,用LRANGE命令从lists中指定一个范围来提取元素 ,RPOP从右侧弹出数据。来看几个例子:
//新建一个list叫做mylist,并在列表头部插入元素"Tom"
127.0.0.1:6379> lpush mylist "Tom"
//返回当前mytist中的元素个数
(integer) 1
//在mylist右侧插入元素 "Jerry"
127.0.0.1:6379> rpush mylist "Jerry"
(integer) 2
//在mylist左侧插入元素"Andy"
127.0.0.1:6379> lpush mylist "Andy"
(integer) 3
//列出mylist中从编号到编号1的元素
127.0.0.1:6379> lrange mylist 0 1
1) "Andy"
2) "Tom"
//列出mylist中从编号o到倒数第一个元素
127.0.0.1:6379> lrange mylist 0 -1
1) "Andy"
2) "Tom"
3) "Jerry"
//从右侧取出最后一个数据
127.0.0. 1:6379> rpop mylist
"Jerry"
//再次列出mylist中从编号到倒数第一个元素
127.0.0.1:6379> lrange mylist 0 -1
1) "Andy"
2) "Tom”
set集合
set是无序不重复集合,list是有序可以重复集合,当你需要存储一个列表数据, 又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要功能,这个也是list所不能提供的。
可以基于set轻易实现交集、并集、差集的操作。比如:在微博应用中,可以将一个用户所有的关注人存在一个集合中, 将其所有粉丝存在一个集合Redis可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能,也就是求交集的过程。set具体命令如下:
//向集合myset中加入一个新元素"Tom"
127.0.0.1:6379> sadd myset "Tom"
(integer) 1
127.0.0.1:6379> sadd myset "Jerry"
(integer) 1
//列出集合myset中的所有元素
127.0.0.1:6379> smembers myset
1) "Jerry"
2) "Tom"
//判断元素Tom是否在集合myset中,返回1表示存在
127.0.0.1:6379> sismember myset "Tom"
(integer) 1
//判断元素3是否在集合myset中,返回o表示不存在
127.0.0.1:6379> sismember myset "Andy"
(integer) 0
//新建一个新的集合yourset
127.0.0.1:6379> sadd yourset "Tom"
(integer) 1
127.0.0.1:6379> sadd yourset "John"
(integer) 1
127.0.0.1:6379> smembers yourset
1) "Tom"
2) "John"
//对两个集合求并集
127.0.0.1:6379> sunion myset yourset
1) "Tom"
2) "Jerry"
3) "John"
sorted Set有序集合
和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。
在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息, 适合使用Redis中的SortedSet结构进行存储。
很多时候,我们都将redis中的有序集合叫做zsets,这是因为在redis中,有序集合相关的操作指令都是以z开头的,比如zrange、 zadd、 zrevrange、 zrangebyscore等等
来看几个生动的例子:
//新增一个有序集合hostset,加入一个元素baidu.com,给它赋予score:1
127.0.0.1:6379> zadd hostset 1 baidu.com
(integer)1
//向hostset中新增一-个元素bing.com,赋子它的score是30
127.0.0.1:6379> zadd hostset 3 bing.com
(integer) 1
//向hostset中新增一个元素google.com,赋予它的score是22
127.0.0.1:6379> zadd hostset 22 google.com
(integer) 1
//列出hostset的所有元素,同时列出其score, 可以看出myzset已经是有序的了。
127.0.0.1:6379> zrange hostset 0 -1 with scores
1) "baidu.com"
2) "1”
3) "google.com"
4) "22"
5) "bing.com"
6) "30"
//只列出hostset的元素
127.0.0.1:6379> zrange hostset 0 -1
1) "baidu.com"
2) "google.com"
3) "bing.com"
Hash
Hash是一个string 类型的feld和value的映射表,hash 特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。比如我们可以Hash数据结构来存储用户信息,商品信息等等,比如下面我就用hash类型存放了我本人的一些信息:
//建立哈希,并赋值
127.0.0.1:6379> HMSET user:001 username antirez password P1pp0 age 34
OK
//列出哈希的内容
127.0.0. 1:6379> HGETALL user:001
1) "username"
2) "antirez"
3) "password"
4) "P1ppo"
5) "age"
6) "34"
//更改哈希中的某-一个值
127.0.0.1:6379> HSET user:001 password 12345
(integer)0
//再次列出哈希的内容
127.0.0.1:6379> HGETALL user:001
1) "username"
2) "antirez"
3) "password"
4) "12345"
5) "age"
6) "34"