第三章 利其器
摘要:俗话说的好工欲善其事,必先利其器,框架搭的好,开发起来很舒服,搭的不好,开发起来就很痛苦。
一个程序员只会写业务代码,最多算是个码农,搭框架的本事、遇到难题的解决能力、算法能力这些才是决定你身价的技术,需要长期修炼。
本章目标
完成项目框架搭建,基础数据构建
重点:全是重点
文章目录
概述
上一章,完成了总体设计,这一章要完成项目框架搭建的工作,也就是达到能够进行业务功能的开发。
一个程序员能不能胜任,要看能不能做好业务功能的开发,值多少钱就看能你搭出一个什么样的框架了。
搭框架之前还要是思考一下这个框架怎么搭,依据是什么,要解决哪些问题。
依据嘛,肯定是总体设计和项目需求。在搭框架的时候要考虑一下项目需求和总体设计中有没有是公共功能,需求先完成的,这部分功能通常是进行其它业务模块所必须依赖的功能,比如说:登录、权限验证什么的。
此项目通过部署结构的设计避开了权限验证,所以搭框架可以不考虑这一部分功能。
总体来说,我认为搭框架主要解决三大类问题:
-
统一业务模块开发方法
开发方法是什么意思呢?举个最简单的例子,要实现往数据库里添加一条记录,有很多种方法,可以用MyBatis,也可以用MyBatisPllus,MyBatis还可以写到xml,也可以写到注解上面。这只是添加一条记录,能实现的方法还不是太五花八门,我经常在一些老项目中看到,相似的功能有好多种写法,看起来真的是很乱。
再比如说,有很多数据库命名规范约定字段命名中单词之间用下划线分割,但是实体类又要求用骆驼命名法,这样一来就必须得在xml中编写实体类映射文件,否则字段无法映射到实体类上,说实话这种写法我非常讨厌。而字段命名直接用骆驼命名法,就不需要写映射文件,爽的不要不要的。
还有关于ID字段命名的问题,我曾经的团队中出现过,有的人写成Id,有的人写成ID,就带来很多困扰。每次写ID都要先查一下数据库里是怎么命名的。
如此之类的翻车事件,数不胜数,所以开发方法如果不统一,就会造成很多的问题。
统一开发方法通常包含:请求方式、基本命名规范、分页处理方法、基本CRUD写法、一个页面从前到后的常用开发步骤(这个涉及前端路由、AJAX请求、参数验证、封装,后端参数验证封装、DAO层写法、返回数据封装等一整套流程)、日志处理方法、事务处理方法、异常处理机制等等。
-
统一前后端接口格式
前后端统一接口格式是非常常见的做法,统一接口格式不仅仅数据格式的统一,还可以扩展出前后端的消息处理机制,比如有时候后端操作成功或失败后,需要前端弹出不同样式的提示框,而要实现这个效果前端和后端都需要处理,这时候你就可以把弹窗提示的类型也封装在统一的接口格式中,比如可以分为Success、Info、Warning、Error几类。
接口格式不仅仅是返回格式的统一,还包含请求格式的统一。有的同学就说了,请求格式有啥统一的,不都是JSON么,实则不然,比如常用的分页请求,不管对什么表,基本的请求参数是一样的,那我们就可以把分页请求格式统一起来。
除些之外,异常处理机制与接口格式也有关系,因为请求不管成功失败都会给前端返回数据,如果你不想失控,就可以把他们统一起来,这样,即使接口异常,也可以非常友好的给出响应。
-
编写公共类库
公共类库是框架必备的东西,什么会议处理、日期格式处理、空值处理、 数学运算、加密解密等等都包含在这里面。
除了以上的内容之外,还有公共枚举类型的定义,很多时候我们都会用一些int类型来表示枚举,但时间长了int类型表达的含义就常常会弄混,所以最好的办法就是定义成枚举,用最大的可能减少出错的机率。比如项目的返回代码、业务流程中的状态字段等。
下面依次来解决一下
一、创建项目
后端项目
上一章,我们定义了项目文档结构,后端包含三个项目,都放在了server文件夹中,这样分模块有一个好处,几个后端可以变成Module出现在同一个IDEA项目中,避免了来回切换项目的麻烦。
├─server 后端代码,idea中项目的根目录,下面的几个文件夹是idea中的模块
│ ├─collector_server
│ ├─manager_server
│ └─other_server
具体做法是,先在Server目录创建一个IDEA项目。如果你已经创建了文件夹,这一步创建项目的时候可能会提示你文件夹已经存在,这时你可以先把server文件夹删除掉。
创建的时候第一步选择Maven项目,然后创建一个空项目就好了。
创建完之后应该是这样的,其中src文件夹可以删掉,因为server只是一个目录,我们需要在server中创建几个模块(Module)。
创建模块要这样:
在下面这一步,像artifact、package还是要修改一下,不要采用默认的,因为咱们的文件夹命名不太符合Java的命名习惯。
默认Maver包选一下:
collector_server、manager_server、other_server三个项目创建完之后再创建一个common模块,用来放一些公共代码,让其它三个模块引用。注意common模块不要用 Spring向导创建,直接创建成空的maven项目就好了。
最后创建完之后是这样的,
前端项目
然后我们再来搭前端的项目结构,方法和后端其实是类似的,我用的是HBuilderX,打开项目是从web目录打开的,所以打开之后天然就能看到下面的几个文件夹。
└─web 前端代码,vscode/hbuilder的根目录,省去打开多个项目窗口来回切换的麻烦
├─collect_web
├─manager_web
├─reciever_web
├─transfer_web
└─uploader_web
然后打开终端窗口,切换到 web文件夹,把项目依次创建出来。注意创建的时候先把五个子文件夹删除掉,因为npm init vue命令创建项目的时候会自动创建文件夹。
接下来依次输入下面的命令来创建vue项目,默认会创建VUE3的项目。
npm init vue
npx: installed 1 in 1.348s
Vue.js - The Progressive JavaScript Framework
√ Project name: ... collector_web //注意这一步文件名字
√ Add TypeScript? ... No / Yes
√ Add JSX Support? ... No / Yes
√ Add Vue Router for Single Page Application development? ... No / Yes //这一步选择YES,其它默认选NO就可以了。
√ Add Pinia for state management? ... No / Yes
√ Add Vitest for Unit Testing? ... No / Yes
√ Add Cypress for both Unit and End-to-End testing? ... No / Yes
√ Add ESLint for code quality? ... No / Yes
Scaffolding project in D:\Code\NATPlatform\demo\collector_web...
Done. Now run:
cd collector_web
npm install
npm run dev
创建完成之后是这样的,建议可以先创建出一个移动端的,把框架搭好之后直接复制文件到其它文件夹。
PC端则可以另外搭建。或者你也可以选择移动端用vant提供的创建项目的方式,PC端选择elementUI提供的创建方式。
二、约定开发方法
刚刚上面举例了几种开发方法,下面把约定一一列出,好在开发的过程中统一动作,这一点在团队开发中尤其重要,优秀的团队写的代码像一个人写的,阅读起来很舒服。
-
后端的Mapping注解优先采用PostMapping,禁止采用RequestMapping,参数接收统一采用RequestBody,只接收Json请求参数。前端发送请求优先采用用POST方式,尽可能不用Get请求方式,提交数据采用JSON格式。上传文件请求除外。
@PostMapping("login") public ResultModel<Collector> login(@RequestBody @Valid LoginModel model) { }
-
前端提交数据时即使传一个ID,也要采用Josn格式,禁止直接传数字,后端可以用实体类或BO类来接收。
-
提交表单时、前端要做验证,后端也要做验证,验证方法使用注解。
-
业务异常使用自己定义的业务异常类抛出,交由全局异常处理统一返回异常数据包。业务异常指业务逻辑中判断的非正常流程,例如:用户密码错误、开管时试管码已经使用、封箱时箱码中还有未封管的试管码等等。
throw new BusinessException("用户名或密码不正确",ResultCodeEnum.LOGIN_ERROR);
-
common模块的说明
- common模块存放各个模块要使用的公共代码,如全局异常处理
- 实体类存放于common模块,注意在写xml时引用resultType写完整的包名。
- 公共controller接口可以放在common模块,例如行政区划相关的查询接口。
- common模块禁止放写操作的接口,写操作接口只允许放在业务模块
-
SQL语句禁止写在方法的注解中,统一写在XML文件中,禁止使用MyBatisPlus。
<select id="login" resultType="com.hawkon.common.pojo.Collector"> select * from collector where tel = #{tel} and password=#{password}; </select>
-
数据库表中标识列命名规范为:personId,userId,禁止使用如下命名规则:id、ID、userID、user_id、user_ID。这样命名有两个好处,一是不用写映射;二是即使要写表连接,也不需要写映射xml。
-
数据库表名单词之间用下划线分隔,原因是windows系统中,mysql表名不区别大小写,如果用骆驼命名法,最后都会变成小写,给开发带来一些困扰。
-
xml查询返回类型原则上返回实体类,如实体类无法满足需要,则定义为VO类,VO类可以结成实体类来做扩展。
特别适合表中有userId,界面显示的时候还需要userName这样的情况。
public class Point{ private Integer pointId; private String pointName; private Integer createUserId; } // 查询的SQL语句: // select p.*,u.userName as createUserName from point p inner join user u on u.userId = p.createUserId public class PointVO extends Point{ private String createUserName; }
-
表中字段名采用骆驼命名法,禁止采用下划线分割单词。这样就不需要在xml文件中配置映射关系。减少代码编写量。
-
所有pojo类采用@Data注解,自动生成get\set方法。如有必要做额外的处理,可以手写get\set方法。例如:下面的分页查询参数实体类,定义resetRowBegin方法,并在page、size的set方法中调用,目的是为自动生成rowBegin,rowBegin是在sql语句中的limit后面使用的参数。
@Data public class BasePagedSearchModel { /** * 页码,从1开始 */ private Integer page; private Integer size; private Integer rowBegin; //此方法的目的是page和size更改之后自动计算rowBegin private void resetRowBegin(){ rowBegin = (page-1)*size; } public Integer getPage() { return page; } public void setPage(Integer page) { this.page = page; resetRowBegin(); } public Integer getSize() { return size; } public void setSize(Integer size) { this.size = size; resetRowBegin(); } }
-
接入的入参必须定义入参pojo类,统一放在pojo.bo包中,如方法为:Login,则pojo类命名为:LoginBO。如公共BO类能够满足需要可以不需要单独定义。
@RequestMapping("login") public ResultModel<Collector> login(@RequestBody @Valid LoginModel model) { } @Data public class LoginModel { @NotEmpty(message = "手机号不能为空") @Size(min = 11, max = 11,message = "手机号必须是11位") private String tel; @NotEmpty(message = "密码不能为空") @Size(min = 6,message = "密码至少6位 ") private String password; }
-
前端VUE文件统一采用组合式编写方式。
<script setup> import { ref } from 'vue'; import { Toast } from 'vant'; import api from '@/common/api.js'; import { useRouter } from "vue-router"; const router = useRouter(); const loginForm = ref({tel:'1877777777',password:'123'}); const now = new Date(); const onSubmit = (values) => { api.post("/collector/login",loginForm.value) .then(res=>{ window.sessionStorage["user"] = JSON.stringify(res.data); router.push("/SelectPoint"); }) .catch(res=>{ console.log("错误",res) }) }; </script>
-
时间格式处理:默认情况采用2022-01-09这样的格式。实体类中如有时间类型,需要加注解设置默认格式。
@DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") private Date registTime;
以上开发方法的约定,有些可能未必是广泛采用的命名规范,也有一些并不开发效率最高的方式,例如禁止使用MyBatisPlus,但是在团队中其实只要约定统一,出现问题的时候所有人什么样的代码在什么地方,这样真正开发的时候效率并不一定差多少。说到底开发规范只是一个选择,并没有对错之分。
很多约定都是实际工作中总结出来的,初学者往往并不能意识到这是个问题,在工作中遇到问题多思考多总结,逐渐找到适合自己和团队的开发方法和约定。
三、约定前后端接口格式
初学者很多都是直接返回数据,比如返回一个对象,给到前端的json就是:
{
"name":'tom',
"age":32
}
而企业实际开发中可能是这样的:
{
code:200,
data:{
"name":'tom',
"age":32
},
message:'',
dialogType:'success'
}
他们把实际返回的数据做了统一的包装,除了数据之外,还包含的一些其它的信息,这些信息可以给前端用来做统一的异常处理、消息提醒、反馈机制等。
我们的项目的接口格式则定义为:
{
code:0,代码接口响应代码,0表示成功,100开头表示服务器错误
data:{
//数据
},
errMsg:''
}
后端定义统一的返回类型:
package com.hawkon.common.pojo;
import lombok.Data;
@Data
public class ResultModel<T> {
private Integer code;
private T data;
private String errMsg;
public ResultModel(ResultCodeEnum codeEnum, T data, String errMsg) {
this.code = codeEnum.getCode();
this.data = data;
this.errMsg = errMsg;
}
//大多数据情况下使用该方法返回成功的响应结果,尽量减少业务代码编写量
public static<T> ResultModel<T> success(T data){
return new ResultModel<>(ResultCodeEnum.SUCCESS,data,"");
}
}
其中ResultCodeEnum是表示接口响应代码,用枚举定义是为了防止胡乱定义响应代码,带来不必要的麻烦。
package com.hawkon.common.enums;
/**
* 接口响应代码
*/
public enum ResultCodeEnum {
SUCCESS(0),
/**
* 其它服务器代码错误
*/
ERROR(100),
/**
* 登录错误
*/
LOGIN_ERROR(101),
/**
* 登录状态失效
*/
NOT_LOGIN(102),
/**
* 参数验证错误
*/
PARAMS_ERROR(103);
private int code;
ResultCodeEnum(int i) {
this.code = i;
}
public int getCode() {
return code;
}
}
四、全局异常处理
全局异常处理前面已经提到很多次,这些说具体的做法,首先定义BusinessException,用来专门抛出业务异常。
package com.hawkon.common.exception;
import com.hawkon.common.pojo.ResultCodeEnum;
import lombok.Data;
@Data
public class BusinessException extends Exception{
private ResultCodeEnum resultCode;
public BusinessException(String message, ResultCodeEnum resultCode) {
super(message);
this.resultCode = resultCode;
}
}
package com.hawkon.common.exception;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.pojo.ResultModel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.util.stream.Collectors;
@Slf4j
@ControllerAdvice
public class GolbalException {
@ExceptionHandler(value = Exception.class)
@ResponseBody
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResultModel<Object> handle(Exception e) {
if (e instanceof BusinessException) {
BusinessException be = (BusinessException) e;
log.error("业务逻辑处理异常:{}", (be).getMessage());
log.trace(e.getStackTrace().toString());
e.printStackTrace();
return new ResultModel<>(be.getResultCode(),null,be.getMessage());
}
if(e instanceof MethodArgumentNotValidException){
MethodArgumentNotValidException me = (MethodArgumentNotValidException) e;
log.error("业务逻辑处理异常:{}", (me).getMessage());
log.trace(e.getStackTrace().toString());
e.printStackTrace();
String errMsg = me.getBindingResult().getAllErrors()
.stream().map(err->err.getDefaultMessage()).collect(Collectors.joining(","));
return new ResultModel<>(ResultCodeEnum.PARAMS_ERROR,null,errMsg);
}
log.error("系统异常:{}", e);
return new ResultModel<>(ResultCodeEnum.ERROR,null,"系统错误");
}
}
因为各个后端模块都需要使用,所以这两个类写在了common模块中,而在业务模块中需要引用common公共代码模块,引用方式见第五小节。
然后需要在SpringBoot的启动类上添加,CompnentScan注解,确保SpringBoot启动的时候会扫描common类库中的代码
@ComponentScan(basePackages = {
"com.hawkon.collector.*","com.hawkon.common"})
五、公共类库
公共类库中代码主要包含:
- 全局异常处理
- 统一接口格式定义
- 分页查询接口参数pojo类
- 工具类
后端的公共类库
完成后的模块内类结构如下:
、
下面贴出前面没有贴出来代码类:
Global.java:主要用来全局获取request、response对象,Web项目必备神器。这样你用不用把request、response对象来回的传了。
package com.hawkon.common.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class Global {
public static HttpServletRequest request;
public static HttpServletResponse response;
@Autowired
HttpServletRequest httpServletRequest;
@Autowired
HttpServletResponse httpServletResponse;
@PostConstruct
public void init(){
request = httpServletRequest;
response = httpServletResponse;
}
}
Md5Util.java:md5加密算法工具类,不用多讲。本项目先用最基本版的加密方法,在最后一章(继续修炼)中再延伸更高级一点的加密方法。
package com.hawkon.common.utils;
import sun.misc.BASE64Encoder;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class Md5Util {
public static String encode(String str) throws NoSuchAlgorithmException, UnsupportedEncodingException {
//确定计算方法
MessageDigest md5=MessageDigest.getInstance("MD5");
BASE64Encoder base64en = new BASE64Encoder();
//加密后的字符串
String newstr=base64en.encode(md5.digest(str.getBytes("utf-8")));
return newstr;
}
}
注意,并不所有的公共代码都适合放在common模块中,比如在collector模块中我写了设置和获取session登录状态的公共代码,这部分只适合放在collector模块中。
还要注意,公共类库的代码写在common模块中,他需要被其它模块引用,引用方式为:
第一步:模块上点右键,选择Open Module Setting
选择Dependencies,Module Dependency。选择common模块。
操作完成后在pom.xml文件中会看到如下代码,如果你写pom文件比较熟练的话,也可以直接改pom文件。
<dependency>
<groupId>com.hawkon</groupId>
<artifactId>common</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
前端的公共类库
前端的公共类库要做的有
模块配置
vue3默认是不开启局域网访问的,这样就不能够使用手机来设计前端页面。开启方法也很简单,在package.json中找到scripts里面的dev,在他后面加上–host就可以了。
/package.json
{
"name": "collector_web",
"version": "0.0.0",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview --port 4173"
},
//....
}
第二个主要的配置就是反向代理,这个是前端联调必须要搞定的,否则无法联调。找到vite.config.js,
添加server节点的配置内容。其中proxy指的就是代理,"/api"指的就是前端发起的所有/api开头的请求会由代理来转发。
转发至target定义的服务器,但是我们的后端服务器地址其实并没有/api开头的路径,所以写了rewrite把/api替换为空字符串。比如前端访问地址是:http://localhost:5143,那么登录功能请求的地址就是:http://localhost:5143/api/collector/login,经过代理之后就会发向http://localhost:8091/collecotr/login,正好是我们后端服务器的接口地址。
但是这个请求并不是由前端页面直接发向8091的,而是由nodejs中的vite js模块反向代理发向8091的,这样也就不存在跨域请求的问题了。
完整代码如下:
/vite.config.js
import {
fileURLToPath,
URL
} from 'node:url'
import {
defineConfig
} from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from 'unplugin-vue-components/vite';
import {
VantResolver
} from 'unplugin-vue-components/resolvers';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(),
Components({
resolvers: [VantResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src',
import.meta.url))
}
},
server: {
proxy: {
'/api': {
//
target: 'http://localhost:8091', // 后端代码地址和端口
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '') // 重写路径
}
}
}
})
第三个配置,移动端模块使用vantUI组件,为方便使用,可以做全局引用,这样就不用每个页面写引用代码了。
第一步:安装vantUI,在模块目录下执行npm install vant
第二步:在vite.config.js中添加代码,就是上面vite.config.js文件中的12-14行和18-22行。
import Components from 'unplugin-vue-components/vite';
import {
VantResolver
} from 'unplugin-vue-components/resolvers';
plugins: [vue(),
Components({
resolvers: [VantResolver()],
}),
],
第三步:main.js中引用 vantUI样式,添加一行代码即可对应的是下面/src/main.js中第4行代码
import 'vant/es/toast/style';
公共方法
定义一些全局使用的常量、工具类。
/src/common/common.js
export default {
VERSION: "V1.0", //需要显示版本号的地方使用
urlPrefix:"/api", //后端请求的统一前缀,用于在api.js中发送请求时统一加前缀。再由反向代码把api开头的请求转发到后端服务器
//返回代码常量,要和后台的常量定义保持一致
RESULT_CODE: {
SUCCESS: 0, ERROR: 100, LOGIN_ERROR: 101,NOT_LOGIN:102,PARAMS_ERROR:103,REGISTER_ERROR:104,BUSSINESS_ERROR:105
},
utils: {
nullToEmpty(obj) {
if (obj == null || typeof (obj) == "undefined") {
return "";
}
return obj;
}
}
}
封装axios
/src/common/api.js
import axios from 'axios'
import {
Toast
} from 'vant'
import common from './common.js'
import router from "../router/index.js";
import cookie from "vue-cookie";
// 创建一个axios实例
const api = axios.create()
api.defaults.baseURL = common.urlPrefix;
//设置请求时把token从cookie中取出,放到header中。
api.interceptors.request.use(function(config) {
// 发送请求的相关逻辑
// config:对象 与 axios.defaults 相当
// 借助config配置token
let token = cookie.get("token")
// 判断token存在再做配置
if (token) {
config.headers.token = token
}
return config
}, function(error) {
// Do something with request error
return Promise.reject(error)
})
// 设置axios拦截器: 响应拦截器
api.interceptors.response.use(res => {
Toast.clear();
// 成功响应的拦截
return Promise.resolve(res.data);
}, err => {
Toast.clear();
var res = err.response.data
if (res.code) {
// 失败响应的status需要在response中获得
switch (res.code) {
// 对得到的状态码的处理,common,是在前端定义的错误代码常量
case common.RESULT_CODE.ERROR: //100
console.log('服务器错误')
Toast.fail("服务器错误");
break;
case common.RESULT_CODE.LOGIN_ERROR: //101
console.log('登录失败')
Toast.fail(res.errMsg);
break;
case common.RESULT_CODE.NOT_LOGIN: //102
console.log('未登录')
Toast.fail('登录状态失效,请重新登录');
sessionStorage.clear();
router.push("/");
console.log('跳转')
break;
case common.RESULT_CODE.PARAMS_ERROR: //103
console.log('参数错误')
Toast.fail(res.errMsg); //如果是参数错误,说明是对参数实体类的验证,有必要提示具体内容
break;
case common.RESULT_CODE.REGISTER_ERROR: //104
console.log('参数错误')
Toast.fail(res.errMsg); //注册错误,信息提示由后端给
break;
case common.RESULT_CODE.BUSSINESS_ERROR: //105
Toast.fail(res.errMsg); //业务逻辑错误,错误信息要提示。
break;
default:
console.log('其他错误')
break
}
} else {
Toast.fail("请求异常");
}
return Promise.reject(res)
})
export default api;
日期过滤器
日期过滤器引用了dayjs,引用方法是先在模块目录下执行npm installl dayjs
然后在main.js中添加代码,第5行,第12-25行。
main.js
import {
createApp } from 'vue'
import App from './App.vue'
import router from './router'
import 'vant/es/toast/style';
import dayjs from 'dayjs'
const app = createApp(App)
app.use(router)
//添加全局日期过滤器
//用法:<span>{
{ $filters.format(scope.row.createDt) }}</span>
app.config.globalProperties.$filters = {
format(value,format) {
if (value) {
if(!format){
format="YYYY-MM-DD";
}
return dayjs(value).format(format)
} else {
return ''
}
}
}
app.mount('#app')
六、开发一个完成的功能点(登录)
以上把约定都做好了,公共代码也完成了,下面可以开发一个功能点了。
为什么搭框架要开发一个功能点呢?难道你没听过程序员的工作模式是CV大法么?虽然是CV大法,你也要知道从哪C,往哪V,CV完了之后要怎么改。这才是一个合格的程序员。
那问题来了,搭框架如果不给出一个规范的功能点,那么下来如果是多人开发的话,功能点的写法就会五花八门,因为大家没有参考。
第一个功能点选择登录是最合适不过的了,即有sql语句,又有业务逻辑,还涉及到登录验证,也是整体框架的组成部分之一。项目有多个模块,我选择采集点模块的登录。
完成之后就可以把代码复制到其它模块,再进行修改了。
后端
好的,我们先来写后端接口。
数据库表结构为:
首先要写一下配置文件:
# 应用名称
spring.application.name=collector_server
#下面这些内容是为了让MyBatis映射
#指定Mybatis的Mapper文件
mybatis.mapper-locations=classpath*:mappers/*.xml
#指定Mybatis的实体目录
mybatis.type-aliases-package=com.hawkon.collector.pojo
# 应用服务 WEB 访问端口
server.port=8091
# 数据库驱动:
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据源名称
spring.datasource.name=defaultDataSource
# 数据库连接地址
spring.datasource.url=jdbc:mysql://localhost:3306/natDb?serverTimezone=UTC&characterEncoding=utf8&allowMultiQueries=true
# 数据库用户名&密码:
spring.datasource.username=root
spring.datasource.password=123
logging.level.com.hawkon.collector.dao=debug
# 设定项目部署的城市
cityCode=4103
provinceCode=41
实体类
package com.hawkon.common.pojo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.util.Date;
@Data
public class Collector {
private Integer collectorId;
@NotEmpty(message="电话号码不能为空")
private String tel;
@NotEmpty(message="身份证号不能为空")
@Pattern(regexp="^([1-6][1-9]|50)\\d{4}(18|19|20)\\d{2}((0[1-9])|10|11|12)(([0-2][1-9])|10|20|30|31)\\d{3}[0-9Xx]$")
private String idcard;
private String password;
@NotEmpty(message = "姓名不能为空")
private String name;
private Integer collectorType;
private Integer organizationId;
@NotNull(message = "所在行政区划不能为空")
private Long areaId;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date registTime;
}
参数BO:
package com.hawkon.collector.pojo.bo;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
@Data
public class LoginModel {
@NotEmpty(message = "手机号不能为空")
@Size(min = 11, max = 11,message = "手机号必须是11位")
private String tel;
@NotEmpty(message = "密码不能为空")
@Size(min = 6,message = "密码至少6位 ")
private String password;
}
CollectorMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hawkon.collector.dao.CollectorDao">
<select id="login" resultType="com.hawkon.common.pojo.Collector">
select *
from collector
where tel = #{tel}
and password = #{password};
</select>
</mapper>
package com.hawkon.collector.dao;
import com.hawkon.collector.pojo.Collector;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface CollectorDao {
public Collector login(@Param("tel") String tel,@Param("password") String password);
}
Service层
package com.hawkon.collector.service;
import com.hawkon.common.pojo.Collector;
import com.hawkon.collector.pojo.bo.LoginModel;
import com.hawkon.common.exception.BusinessException;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
@Service
public interface ICollectorService {
public Collector login(LoginModel model) throws BusinessException, UnsupportedEncodingException, NoSuchAlgorithmException;
public Collector getCollectorByToken(String token);
}
package com.hawkon.collector.service.impl;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.hawkon.collector.dao.CollectorDao;
import com.hawkon.collector.utils.SessionUtil;
import com.hawkon.common.pojo.Collector;
import com.hawkon.collector.pojo.bo.LoginModel;
import com.hawkon.collector.service.ICollectorService;
import com.hawkon.common.enums.CollectorType;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.utils.Global;
import com.hawkon.common.utils.Md5Util;
import com.hawkon.common.utils.NullUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.servlet.http.Cookie;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
@Service
public class CollectorService implements ICollectorService {
@Autowired
CollectorDao collectorDao;
@Override
public Collector login(LoginModel model) throws BusinessException, UnsupportedEncodingException, NoSuchAlgorithmException {
String md5Password = Md5Util.encode(model.getPassword());
Collector collector = collectorDao.login(model.getTel(), md5Password);
if (collector == null) {
throw new BusinessException("用户名或密码不正确", ResultCodeEnum.LOGIN_ERROR);
}
String token = getToken(collector);
//把token存到cokkie中,并设置过期时间,一天
Cookie cookie = new Cookie("token", token);
cookie.setPath("/");
cookie.setMaxAge(7 * 24 * 60 * 60);
Global.response.addCookie(cookie);
//返回前端之前要把密文的密码清除掉。
collector.setPassword(null);
return collector;
}
public String getToken(Collector user) {
Date start = new Date();
long currentTime = System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000;//7天有效时间
Date end = new Date(currentTime);
String token = "";
token = JWT.create().withAudience(user.getCollectorId().toString()).withIssuedAt(start).withExpiresAt(end)
.sign(Algorithm.HMAC256(user.getPassword()));
return token;
}
@Override
public Collector getCollectorByToken(String token) {
String userId = JWT.decode(token).getAudience().get(0);
if (NullUtil.isEmpty(userId)) {
return null;
}
Integer collectorId = Integer.parseInt(userId);
Collector collector = collectorDao.getCollectorById(collectorId);
return collector;
}
}
Controller层
package com.hawkon.collector.controller;
import com.hawkon.common.pojo.Collector;
import com.hawkon.collector.pojo.bo.LoginModel;
import com.hawkon.collector.service.ICollectorService;
import com.hawkon.collector.utils.SessionUtil;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.pojo.ResultModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
@RestController
@RequestMapping("/collector")
public class CollectorController {
@Autowired
ICollectorService collectorService;
@PostMapping("login")
public ResultModel<Collector> login(@RequestBody @Valid LoginModel model) throws BusinessException, UnsupportedEncodingException, NoSuchAlgorithmException {
Collector collector = collectorService.login(model);
//session也存储一下,取登录用户直接从session取,减少sql查询请求
SessionUtil.setCurrentUser(collector);
return ResultModel.success(collector);
}
@PostMapping("logout")
public ResultModel<Object> logout() {
SessionUtil.clear();
//清除cookie
Cookie cookie = new Cookie("token", "");
cookie.setPath("/");
cookie.setMaxAge(0);
Global.response.addCookie(cookie);
return ResultModel.success(null);
}
}
模块内公共代码
package com.hawkon.collector.common;
public class Consts {
/**
* 定义统一的会话名称
*/
public static final String SESSION_USER_KEY = "collector";
}
package com.hawkon.collector.utils;
import com.hawkon.collector.common.Consts;
import com.hawkon.common.pojo.Collector;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.utils.Global;
/**
* session工具类
*/
public class SessionUtil {
public static Collector getCurrentUser() throws BusinessException {
Object obj = Global.request.getSession().getAttribute(Consts.SESSION_USER_KEY);
if (obj == null) {
return null;
}
if (obj instanceof Collector) {
return (Collector) obj;
} else {
return null;
}
}
public static void setCurrentUser(Collector collector) throws BusinessException {
Global.request.getSession().setAttribute(Consts.SESSION_USER_KEY, collector);
}
public static void clear() {
Global.request.getSession().invalidate();
}
}
最后处理一下登录拦截器,让没有登录的情况下不允许访问业务接口。这部分代码严格来说其实也是公共代码的部分。
package com.hawkon.collector.common;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.JWTVerifier;
import com.hawkon.collector.service.ICollectorService;
import com.hawkon.common.enums.ResultCodeEnum;
import com.hawkon.common.exception.BusinessException;
import com.hawkon.common.pojo.Collector;
import com.hawkon.collector.utils.SessionUtil;
import com.hawkon.common.utils.Global;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.websocket.Session;
import java.lang.reflect.Method;
@Component
public class UserLoginInterceptor implements HandlerInterceptor {
@Autowired
ICollectorService collectorService;
/***
* 在请求处理之前进行调用(Controller方法调用之前)
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//统一拦截(查询当前session是否存在collecotr)
Collector user = SessionUtil.getCurrentUser();
if (user != null) {
return true;
}
// return false;
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//检查是否有passtoken注释,有则跳过认证
if (method.isAnnotationPresent(PassToken.class)) {
PassToken passToken = method.getAnnotation(PassToken.class);
if (passToken.required()) {
return true;
}
}
String token = Global.request.getHeader("token");// 从 http 请求头中取出 token
if (token == null) {
throw new BusinessException("非法请求,无登录令牌", ResultCodeEnum.NOT_LOGIN);
}
Collector collector = collectorService.getCollectorByToken(token);
if (collector == null) {
throw new BusinessException("登录状态过期", ResultCodeEnum.NOT_LOGIN);
}
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(collector.getPassword())).build();
try {
jwtVerifier.verify(token);
} catch (JWTVerificationException e) {
throw new BusinessException("登录令牌过期,请重新登录",ResultCodeEnum.LOGIN_ERROR);
}
//如果令牌有效,把登录信息存到session中,这样如果需要用到登录信息不用总到数据库查询。
SessionUtil.setCurrentUser(collector);
return true;
//该方法没有做异常处理,因为在SessionUtil中已经处理了登录状态的异常。只要getCurrentUser()返回有值肯定就是成功的。
}
/***
* 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后)
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
/***
* 整个请求结束之后被调用,也就是在DispatchServlet渲染了对应的视图之后执行(主要用于进行资源清理工作)
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
编写配置类,没有这个,拦截器无法生效的。
package com.hawkon.collector.config;
import com.hawkon.collector.common.UserLoginInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class LoginConfig implements WebMvcConfigurer {
@Autowired
UserLoginInterceptor userLoginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册TestInterceptor拦截器
InterceptorRegistration registration = registry.addInterceptor(userLoginInterceptor);
registration.addPathPatterns("/**"); //所有路径都被拦截
//本项目计划是前后端分享部署,其实下面的代码除了/login需要以外,其它并不是必须的。不过可以保留。
registration.excludePathPatterns( //添加不拦截路径
"/collector/login", //登录路径
"/collector/register", //注册方法
"/collector/forget", //忘记密码
"/area/**/*", //请求区域数据
"/**/*.html", //html静态资源
"/**/*.js", //js静态资源
"/**/*.css" //css静态资源
);
}
}
PassToken.java:跳过Token验证,哪个方法不需要登录权限可以加个注解,不需要总去改config了。
package com.hawkon.collector.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 用来跳过Token验证的注解
*/
@Target({
ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
boolean required() default true;
}
后端写完可以先用apipost看看请求的效果,注意观察红框部分。
错误的情况
成功的情况
登录成功时要注意保存token,并在测试其它接口时添加到header中
后端完成之后的代码结构,截图中包含一部分其它功能的代码。
再来看一下common模块的代码结构
前端
要实现完成的登录功能,前端至少要有两个页面,一个是登录页面,一个是登录之后要跳转的页面。
除了要实现登录业务逻辑之外,还要实现登录的路由守卫,要保证在没有登录的情况下不能够访问业务界面。
那么前端就同样需要保存登录状态,我们可以使用sessionStorage来保存。 这种方法说实在的,比较低级,实际项目中还是不建议这么干,因为稍微懂点开发的人就能绕过去,直接进到业务界面。所以后端也需要判断登录状态。
项目创建完成之后,首先要把原有的代码清理干净。
其中App.vue清理的时候要注意,保留一个RouterView标签,清理后代码如下:
<script setup>
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped>
</style>
清理完成之后,先创建Login.vue页面和SelectPoint.vue页面。
Login.vue,使用了vantui组件.
/src/views/Login.vue
<script setup>
import {
ref
} from 'vue';
import {
Toast
} from 'vant';
import {
RouterLink,useRouter
} from 'vue-router'
//引用封装过后的axios组件
import api from '@/common/api.js';
const router = useRouter();
const loginForm = ref({
tel: '18638898990',
password: '156011'
});
const now = new Date();
const onSubmit = (values) => {
api.post("/collector/login", loginForm.value)
.then(res => {
//代码到这里一定是登录成功,因为失败的时候会被api.js中的拦截器处理掉。
//成功的时候把返回的数据保存在sessionStorage中,因为sessionStorage只能保存String,所以要用JSON.stringify转换一下。
window.sessionStorage["user"] = JSON.stringify(res.data);
//跳转路由
router.push("/SelectPoint");
})
.catch(res => {
console.log("错误", res)
})
};
</script>
<template>
<van-row>
<van-col span="24">
<h2 style="text-align: center;">全场景疫情病原体检测信息系统</h2>
</van-col>
<van-col span="24">
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field v-model="loginForm.tel" name="tel" label="手机号" placeholder="请输入手机号"
:rules="[{ required: true, message: '请填写手机号' }]" />
<van-field v-model="loginForm.password" type="password" name="password" label="密码"
placeholder="默认密码为身份证后6位" :rules="[{ required: true, message: '请填写密码' }]" />
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
提交
</van-button>
</div>
</van-form>
</van-col>
<van-col span="12" style="padding-left: 1em;">
<RouterLink to="/Register">注册</RouterLink>
</van-col>
<van-col span="12" style="text-align: right;padding-right: 1em">
<RouterLink to="/Forget">忘记密码</RouterLink>
</van-col>
</van-row>
</template>
SelectPoint.vue:先能够显示页面就好。
/src/views/SelectPoint.vue
<script setup>
</script>
<template>
选择采集点
</template>
然后修改路由文件/src/router/index.js。注意路由中引用组件有两种写法,建议采用第23行的写法。因为路由一多,写这部分代码就要上下来回拖动滚动条,挺烦人的。
顺便写一下路由守卫功能,实现没有登录的时候无法访问业务界面
router.beforeEach((to, from) => {
//避免路由循环,会带来灾难性BUG:页面卡死,权限不受控的页面都要判断
if (to.name == 'Login'||to.name=="Home"||to.name=="Register"||to.name=="Forget") {
return true;
}
//如果sessionStorage["user"]中有值,说明有可能是登录状态
if (sessionStorage["user"]) {
var user = JSON.parse(sessionStorage["user"]);
//再判断一下SessionStorage中保存的user是否有collectorId字段,避免被人为放入一个非法的字符串
if (sessionStorage["user"] && user && user.collectorId) {
return true;
}
}
return {
name:"Login"};
})
/src/router/index.js
import {
createRouter,
createWebHistory
} from 'vue-router'
import Login from '../views/Login.vue'
const router = createRouter({
history: createWebHistory(
import.meta.env.BASE_URL),
routes: [{
path: '/',
name: 'Home',
component: Login
},
{
path: '/Login',
name: 'Login',
component: Login
},
{
path: '/SelectPoint',
name: 'SelectPoint',
component: () => import('../views/SelectPoint.vue')
}
]
})
router.beforeEach((to, from) => {
//避免路由循环,会带来灾难性BUG:页面卡死,权限不受控的页面都要判断
if (to.name == 'Login'||to.name=="Home"||to.name=="Register"||to.name=="Forget") {
return true;
}
//如果sessionStorage["user"]中有值,说明有可能是登录状态
if (sessionStorage["user"]) {
var user = JSON.parse(sessionStorage["user"]);
//再判断一下SessionStorage中保存的user是否有collectorId字段,避免被人为放入一个非法的字符串
if (sessionStorage["user"] && user && user.collectorId) {
return true;
}
}
return {
name:"Login"};
})
export default router
最后来看看登录的效果吧
终于我们的框架搭完了,别忘了GIT提交一下,俗话说的好工欲善其事,必先利其器,框架搭的好,开发起来很舒服,搭的不好,开发起来就很痛苦。
一个程序员只会写业务代码,最多算是个码农,搭框架的本事、遇到难题的解决能力、算法能力这些才是决定你身价的技术,需要长期修炼。
想跟着一起敲出这个项目的同学可以三连一下,不迷路。