目前我们已经拥有了一个前端登录界面,也了解了前端对于用户识别的相关机制,现在开始撸后端api server,完成用户登录功能。
和语言本身无关,Javascript和Java的生态比C++简直好了不要太多,社区力量超级活跃,而C++生态就像一个资历颇高的垂暮老人,一个17标准直到现在都没有让各类主流编译器全部实现,开发人员要为了在不同平台实现一些基本功能重复造轮子。Javascript和Java太多开箱即用的东西了,从软件工程角度来看完爆C++。不过对于一门语言,封装的东西越多,概念越抽象,坑也就隐藏得更深更隐蔽,这会使得开发人员容易入门,成为专家却更难。
话说IDEA真是一个好工具,简单几步操作就能生成一个restful api服务器框架,用习惯了就会感受到其作为一个IDE功能无比强大!
API Server工程的创建
- New->Project->Spring Initializr->Next
- 稍微等待以后填入Group,Artifact,工程管理我这边选择的是Maven,继续点击Next后出现列表界面
- 由于我只是想做一个纯的api server,用户认证机制也全部自己来实现,所以只需要选择Spring Web,Spring Data JPA,MYSQL Driver这几个模块就行,当然Lombok也要带上,懒人必备。最后的选项如下图所示
- 继续Next,选择好项目路径即Finish了
工程自带一个空的application.properties,这是整个项目的配置文件,我比较喜欢yml格式的配置文件,把它改名称application.yml,先做好WEB端口,数据库连接等基本配置:
server:
servlet:
context-path: /
port: 10200
spring:
profiles:
active: dev
datasource:
url: jdbc:mysql://localhost:3306/pf_report?serverTimezone=Hongkong&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password:
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
database: mysql
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL5InnoDBDialect
servlet:
multipart:
max-file-size: 1000MB
max-request-size: 1000MB
刚创建工程后,maven会下载一堆依赖包,我NAS上部署了一个Nexus仓库,常用依赖包的下载会快不少。等依赖包下载完成就可以运行了。
配置好对应的数据库服务后,我们现在开始来一步步完善这个api server。
API Server登录接口的实现
上一篇文章总结过,前端request组件会对服务端响应结果进行拦截,这对服务端的响应格式有一定要求,因此,我们先定义一个通用的VO结构,用于服务端向客户端返回数据。这个类的名称我定为ApiResult,一共3个成员,分别是code、message和data。code和message是前端统一要求的,而data成员则可以存放各个接口想要返回的任意数据。
package com.zy.report.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ApiResult {
private long code;
private Object data;
private String message;
public ApiResult(long code){this.code = code; data = null;}
public ApiResult(long code, Object data){this.code = code; this.data = data;}
}
这里用到了lombok的注解,能够为我们的类自动生成构造函数、getter和setter。
在实现login接口前先查阅一下前端代码关于该接口的参数定义,可以在 views/login/index.vue 中看到以下代码片段
loginForm: {
username: "admin",
password: "111111"
},
//此处省略
this.$store
.dispatch("user/login", this.loginForm)
.then(() => {
this.$router.push({ path: this.redirect || "/" });
this.loading = false;
})
在api/user.js中能查看到登录接口的URL定义
export function login(data) {
return request({
url: '/user/login',
method: 'post',
data
})
}
于是,我们先定义一个请求对象,然后根据api的url为服务端添加Controller
package com.zy.report.req;
import lombok.Data;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReqLogin {
private String username;
private String password;
}
注意哦,这里一定要加上@NoArgsConstructor注解
package com.zy.report.controller;
import com.zy.report.req.ReqLogin;
import com.zy.report.vo.ApiResult;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/user")
public class LoginController {
@RequestMapping("/login")
@ResponseBody
public ApiResult login(HttpServletRequest request, @RequestBody ReqLogin params) {
if(!(params.getUsername().equals("admin") && params.getPassword().equals("111111")))
return new ApiResult(20001, null, "用户名或密码不正确");
Map<String, Object> r = new HashMap<>();
r.put("token", "test-token");
return new ApiResult(20000, r);
}
}
当然,controller里面的login接口其实什么也没做,只是校验了一下前端传来的用户名和口令是否为默认值,然后按照接口约定,返回一个测试用的token字段而已。如果前面步骤正确,api server就可以正常运行了。
初遇跨域错误
回到显示着登录界面的浏览器,使用默认用户名和密码,点击登录按钮,却出现了这个错误:
F12 查看控制台
Access to XMLHttpRequest at 'http://localhost:10200/user/login' from origin 'http://localhost:9527' has been blocked by
CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header
is present on the requested resource.
嗯,虽然api server和前端都是运行在本地,但我们的api server是运行在10200端口,而vue-clie运行的前端是在9527端口,这的确会产生跨域问题。在真实场景部署下,我们可以通过ngnix将api server和前端web服务代理到同一个端口上,但对于开发过程来说,每次调试都要重新部署确实有些麻烦,我们可以通过对api server进行配置来启用跨域。
我们在api server项目中添加一个新的配置类
package com.zy.report.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
@Configuration
public class CORSConfiguration extends WebMvcConfigurationSupport {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods("*")
.allowedOrigins("*")
.allowedHeaders("*");
super.addCorsMappings(registry);
}
}
withCredentials引起的跨域错误
这样,api server上所有的接口就应该支持跨域了。重启api server,回到浏览器点击登录
Network Error依旧!!!怎么回事?还是查看控制台,发现这次的错误提示有些不一样:
Access to XMLHttpRequest at 'http://localhost:10200/user/login' from origin 'http://localhost:9527' has been blocked by CORS
policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in
the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests
initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
提示里说的很明白,页面在发起XHR时,启用了withCredentials参数,那服务端在返回Access-Control-Allow-Origin就不能使用通配符*了。
withCredentials参数为true时,意味着浏览器发现XHR是跨域访问时,也要把原网站的Cookie数据一并发送出去,因为Cookie里经常会存放一些用户敏感数据,这样的跨域访问的确是会带来安全风险,所以在这种情况下XHR就直接被浏览器阻止了。
改服务端代码有点麻烦,而且我们的用户认证机制并没有使用cookie,查看前端代码request组件的实现时,发现withCredentials设置为true,那我们就关掉这个参数再试试?在 utils/request.js 中注释掉withCredentials这一行。
// create an axios instance
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 5000 // request timeout
});
保存,等待vue-cli热刷新完毕,再次回到浏览器点击登录,Network Error依旧!
查看控制台,会发现这次错误又不一样了,值得庆祝的是跨域问题终于解决了,服务端也正确的返回了token
继续探索前端登录流程
我们也不难发现这次的错误是由于服务端一个未实现的info接口引起的。
看来前端代码在获取到token以后会继续通过info接口向服务端获取用户的详细数据。我们只需在服务端实现info接口就可以解决这个问题,但我还想继续探索前端项目的登录流程,于是硬着头皮接着分析前端登录部分的代码。
之前我们分析前端登录代码时忽略了一个细节,那就是前端login接口调用成功后,会将将当前路由设置为’/’,具体代码在 vies/login/index.vue中:
this.$store.dispatch('user/login', this.loginForm)
.then(() => {
this.$router.push({ path: this.redirect || '/' })
this.loading = false
})
.catch(() => {
this.loading = false
})
虽然对vue里router组件了解不多,但大致能够知道router组件控制着前端页面的切换,一是半会儿我没有想到router的切换是如何与info接口调用关联起来的,但通过搜索代码,不难发现在 src/permission.js 中有着这么一段代码实现:
import router from './router'
router.beforeEach(async(to, from, next) => {
//省略
if (hasToken) {
if (to.path === '/login') {
//省略
} else {
// determine whether the user has obtained his permission roles through getInfo
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
// get user info
// note: roles must be a object array! such as: ['admin'] or ,['developer','editor']
const { roles } = await store.dispatch('user/getInfo')
这不正是router的拦截器吗,在用户切换router之前,如果store中还没有用户的权限信息,那就调用info接口获取。先简单看一下info接口之后,前端还做了什么。
// generate accessible routes map based on roles
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
// dynamically add accessible routes
router.addRoutes(accessRoutes)
// hack method to ensure that addRoutes is complete
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true })
嗯,可以看出info接口成功后,就根据返回的角色信息来设置路由了。我也不难理解为什么这个文件叫做permission.js——正是有了这个拦截器,就可以实现不同角色的用户可以访问不同的页面。
OK,摸清楚前端的登录流程后,我们继续思考如何解决之前遇到的问题。
- 我们可以在api server上实现这个info接口
- 我们可以将login接口和info接口合并到一起,将用户的基本信息在login时一并返回
简单起见,我还是选择在api server上实现info接口好了。
info接口的实现
有了前面实现login接口的经验,查找info接口的参数定义也不是什么困难事了,这里直接贴上info接口的代码,跟Login接口放在同一个Controller里就好:
@RequestMapping("/info")
@ResponseBody
public ApiResult info(HttpServletRequest request){
Map<String, Object> r = new HashMap<>();
r.put("username", "111111");
r.put("name", "管理员");
List<String> roles = new ArrayList<>();
roles.add("admin");
r.put("roles", roles);
return new ApiResult(20000, r);
}
重启api server,回到浏览器点击登录
终于看到Dashboard页面了!点击菜单-页面权限,可以看到前端认为登陆的用户是管理员,这与我们info接口返回的数据一致。
至此,我们回顾一下整个前端登录的流程
- 用户点击登录按钮 浏览器发起login请求
- 服务端校验用户名和密码,返回token
- 前端将token记录到store,并切换路由
- 切换路由操作被拦截器拦截,并通过info请求获取用户权限信息,获取成功后根据用户权限设置路由,最终通过next路由到指定页面
最后,我们虽然弄清楚了登录的原理,并成功进入后台首页,但服务端对用户身份信息的识别还需要进一步实现,这部分就留到下次在写。