项目总结 校园点歌系统
项目来源
项目源于校内实验室的纳新题,虚设的情境(校园投票点歌)与在校生所处的环境很切合,但是说实话在业务设计方面存在一些问题。个人在拿到这份需求的时候就感受到很多不完整的地方,但是个人将本项目定位为串通全栈开发全流程的技术培训,业务设计方面的缺陷暂时按下不表。不说不代表不重要,明确需求永远是第一位的,在项目实现过程中个人也因为业务不清晰走了不少弯路。本文旨在回顾整个项目的实现流程,整理实现过程中遇到的问题及其解决过程,在最后总结部分会简单给出个人感受和优化方向。
项目设计
前言
开发流程回顾
由于不是特别大型的项目,按照正常方式来就行,需求简单而且是个人开发,UML 图的绘制也偷个懒,先简单回顾一下开发流程
技术选型
确定采用前后端分离的开发模式,再加上个人现阶段掌握的开发技术有限,技术选型方面的工作基本上不用考虑太多,简单列举一下用到的技术
-
前端
- Bootstrap:使用现成组件,减少样式相关代码量
- jQuery: 引入 Ajax,用于实现与后端的接口交互
- Vue:引入便利的数据绑定机制和标签逻辑控制机制,方便进行数据展示
-
后端
- Spring:引入便利的依赖注入机制和强大的插件支持机制
- Spring MVC:引入 RESTful 架构风格,便于实现简洁的接口形式
- MyBatis:引入便利的数据库持久层框架,减少 JDBC 相关代码
- MyBatis Plus:引入内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作
- MySQL:个人最熟悉的关系型数据库,可以支撑小规模用户量软件系统的数据存取需求
-
部署
- Docker:标准化应用发布,可以跨平台和主机使用;节约时间,方便快速部署和启动
需求分析
给定的需求:学生点歌 + 投票,教师审核的校园点歌系统
个人参考在校实际情况补充的边界:
- 注册用户量不超过千位数
- 在线用户量不超过千位数
- 并发用户量不超过百位数
- 教师角色不会超过十位数
→ MySQL 够用
→ 没必要引入专门的角色权限管理模块
业务分析
用户管理机制
只需实现正常的登录注册功能,角色权限控制直接通过修改数据库实现
学生流程分析
- 点歌
- 投票
- 可投票歌曲查询
- 投票
- 投票结果查询
教师流程分析
- 审批歌曲
- 查询歌曲:输入某首歌曲的名称,查询某首歌曲的票数
- 投票结果查询:方便按投票多少决定播放顺序
系统设计
数据库设计
用户
师生共用一张表
-
基础信息
- 学号、工号
varchar(128) - 姓名
varchar(256) - 密码
varchar(512) - userRole
int- 0:学生
- 1:教师
- 学号、工号
-
通用设计
- createTime:创建时间
datetime - updateTime:更新时间
datetime - isDelete:逻辑删除
tinyint:01
- createTime:创建时间
drop table if exists `user_center`;
-- auto-generated definition
create table user_center
( id bigint auto_increment comment 'id' primary key,
userAccount varchar(128) null comment '学号/工号',
username varchar(256) null comment '姓名',
userPassword varchar(512) null comment '密码',
userRole int default 0 not null comment '0-学生 1-教师'
createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
)
comment '用户';
歌曲
-
基础信息
- 名称
varchar(256) - 歌手
varchar(256) - 播放平台
varchar(256) - 备注
varchar(1024) - 票数
int - songStatus
int- 0:待审核
- 1:审核通过
- 2:审核不通过
- 名称
-
通用设计
- createTime:创建时间
datetime - updateTime:更新时间
datetime - isDelete:逻辑删除
tinyint:01
- createTime:创建时间
drop table if exists `song`;
-- auto-generated definition
create table song
( id bigint auto_increment comment 'id' primary key,
songName varchar(256) null comment '歌曲名称',
singerName varchar(256) null comment '歌手姓名',
platformName varchar(256) null comment '平台名称',
remarks varchar(1024) null comment '备注',
votes int default 0 not null comment '票数'
songStatus int default 0 not null comment '状态 0-未审核 1-审核通过 2-审核未通过 3-假删除',
createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
)
comment '歌曲';
接口逻辑设计
UserController
register
- 判空
- 格式判断
- 长度
- 特殊字符
- 密码与校验密码相同
- 数据库操作 查询账户是否重复
- 密码加密
- 数据库操作 插入数据
- 用户脱敏
- 返回结果
login
- 判空
- 格式判断
- 长度
- 特殊字符
- 密码加密
- 数据库操作 查询账号密码是否匹配
- 用户脱敏
- 登录态记录
- 返回结果
logout
登录态清除
getUserState(测试用)
从 Session 中获取用户状态信息
SongController
addOneSong
点歌
- 判空
- 字符串截取(格式控制)
- 数据库操作 插入数据
- 返回结果
findOneSongForVotes
根据歌名查票数
- 鉴权
- 判空
- 数据库操作 查询数据库
- 返回结果
examineSongs
审核歌曲(改变歌曲状态)
- 鉴权
- 修改歌曲状态
- 获取修改后结果
- 返回结果
deleteSongs
删除歌曲
- 鉴权
- 数据库操作 根据 id 删除歌曲
VoteController
showVotes
- 数据库操作 查询 songStatus 为 1(审核通过)的数据
- 返回结果
vote
- 数据库操作 查询投票前数据
- 数据库操作 修改投票数据
- 数据库操作 查询投票后数据
- 返回结果
辅助函数
isAdmin
鉴权用
- 获取用户登录态
- 权限判断
getUnexaminedSongs
获取未审核歌曲
- 数据库操作 查询 songStatus 为 0(未审核)的数据
- 返回结果
getUnDeleteSongs
获取没被删除的歌曲
- 数据库操作 查询 isDelete 为 0 (未被删除)的数据
- 返回结果
接口参数设计
使用 IDEA 中的 easyYAPI 插件生成接口文档,仅列举展示用,可以跳过不看
UserController
hello
BASIC
Path: /user/hello
Method: GET
REQUEST
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
string |
Response Demo:
register
BASIC
Path: /user/register
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
userAccount | string | 学号 工号 |
username | string | 姓名 |
password | string | 密码 |
checkedPassword | string | 二次输入密码 |
Request Demo:
{
"userAccount": "",
"username": "",
"password": "",
"checkedPassword": ""
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | object | |
|─id | integer | id |
|─useraccount | string | 学号/工号 |
|─username | string | 姓名 |
|─userpassword | string | 密码 |
|─userrole | integer | 0-学生 1-教师 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": {
"id": 0,
"useraccount": "",
"username": "",
"userpassword": "",
"userrole": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
},
"message": "",
"description": ""
}
login
BASIC
Path: /user/login
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
userAccount | string | 学号 工号 |
password | string | 密码 |
Request Demo:
{
"userAccount": "",
"password": ""
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | object | |
|─id | integer | id |
|─useraccount | string | 学号/工号 |
|─username | string | 姓名 |
|─userpassword | string | 密码 |
|─userrole | integer | 0-学生 1-教师 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": {
"id": 0,
"useraccount": "",
"username": "",
"userpassword": "",
"userrole": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
},
"message": "",
"description": ""
}
logout
BASIC
Path: /user/logout
Method: GET
REQUEST
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | object | |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": {},
"message": "",
"description": ""
}
SongController
addOneSong
BASIC
Path: /song/addOneSong
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
songName | string | 歌名 |
singerName | string | 歌手名称 |
platformName | string | 平台名称 |
remarks | string | 备注 |
songStatus | string | 状态 |
Request Demo:
{
"songName": "",
"singerName": "",
"platformName": "",
"remarks": "",
"songStatus": ""
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": {
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
},
"message": "",
"description": ""
}
findOneSongForVotes
BASIC
Path: /song/findOneSongForVotes
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
songName | string | 歌名 |
singerName | string | 歌手名称 |
platformName | string | 平台名称 |
remarks | string | 备注 |
songStatus | string | 状态 |
Request Demo:
{
"songName": "",
"singerName": "",
"platformName": "",
"remarks": "",
"songStatus": ""
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": {
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
},
"message": "",
"description": ""
}
examineSongs
BASIC
Path: /song/examineSongs
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
songs | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
Request Demo:
{
"songs": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
]
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
],
"message": "",
"description": ""
}
getUnexaminedSongs
BASIC
Path: /song/getUnexaminedSongs
Method: GET
REQUEST
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
],
"message": "",
"description": ""
}
deleteSongs
BASIC
Path: /song/deleteSongs
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
songs | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
Request Demo:
{
"songs": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
]
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
],
"message": "",
"description": ""
}
VoteController
showVotes
BASIC
Path: /vote/showVotes
Method: GET
REQUEST
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
],
"message": "",
"description": ""
}
vote
BASIC
Path: /vote/vote
Method: POST
REQUEST
Headers:
name | value | required | desc |
---|---|---|---|
Content-Type | application/json | YES |
Request Body:
name | type | desc |
---|---|---|
songs | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
Request Demo:
{
"songs": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
]
}
RESPONSE
Headers:
name | value | required | desc |
---|---|---|---|
content-type | application/json;charset=UTF-8 | NO |
Body:
name | type | desc |
---|---|---|
code | integer | |
data | array | |
|─ | object | |
|─id | integer | id |
|─songname | string | 歌曲名称 |
|─singername | string | 歌手姓名 |
|─platformname | string | 平台名称 |
|─remarks | string | 备注 |
|─votes | integer | 票数 |
|─songstatus | integer | 状态 0-未审核 1-审核通过 2-审核未通过 3-假删除 |
|─createtime | string | 创建时间 |
|─updatetime | string | 更新时间 |
|─isdelete | integer | 是否删除 |
message | string | |
description | string |
Response Demo:
{
"code": 0,
"data": [
{
"id": 0,
"songname": "",
"singername": "",
"platformname": "",
"remarks": "",
"votes": 0,
"songstatus": 0,
"createtime": "",
"updatetime": "",
"isdelete": 0
}
],
"message": "",
"description": ""
}
编码实现、测试、打包
前言
文档结构分类说明
- 难点:设计时知道会出现,而且已经了解解决方案,但是没跑通或者跑通过但是较复杂的问题
- 已解决问题:设计时不知道会出现,实践时出现,通过临时学习,最终解决了的问题
- 未解决问题:设计时不知道会出现,实践时出现,通过临时学习,最终未解决的问题
难点
统一封装
前言
为什么要进行统一封装?
根本目标是返回前端友好的响应
为什么要为前端提供友好的响应?
从前后端分离的角度看,前后端分离的划分就相当于让前端放弃了业务逻辑的实现职责,专心实现用户交互,这种职责的让出导致后端系统在前端看来是一个黑箱,前端是无法判断是黑箱本身出问题还是自己的使用方式不对的
如何实现返回前端友好的响应?
从格式一致性的角度看,为了实现前端友好的响应,响应形式应该要统一,此处给出一种 BaseResponse(code、data、message、description)的习惯性标准响应格式,无论返回的是正确结果还是错误信息,都用一套格式
从开发分工的角度看,为了实现前端友好的响应,要让前端开发者明确是前端请求的问题,还是后端系统的问题,统一封装通过引入自定义运行时异常(BusinessException)来区分前后端的异常,方便前端定位错误
从提供信息的角度看,为了实现前端友好的响应,应该提供详细的错误信息,此处给出一种 ErrorCode(code、message、description)的习惯性错误信息组织形式
- ErrorCode
- code 可以参考 HTTP 中的状态码进行扩充
- 200:ok
- 4XX:客户端错误
- 5XX:服务器错误
- message 用于描述错误的大类
定性简单描述,通常必填 - description 用于进一步描述错误信息
详细描述,通常选填
- code 可以参考 HTTP 中的状态码进行扩充
补充:关于 AOP 的感受
从实现手段来看,无论是否引入 AOP,正常的返回结果都是在 Controller 中进行封装的,区别在于错误信息的返回是否引入了 AOP 进行全局异常拦截,可以分为
-
不引入 AOP 直接返回错误信息:在出错的地方直接返回带错误信息的统一形式响应
graph LR 请求参数为空 --> 返回请求参数为空的响应 请求参数错误 --> 返回请求参数错误的响应 ... --> 返回...的响应 -
引入 AOP 统一返回错误信息:先抛出异常,再通过全局异常拦截器拦截,在全局异常拦截器进行统一返回统一形式响应(本项目采用的实现手段)
这样做一方面使得响应处理显得没那么零散,另一方面也使得统一记录错误日志成为可能graph LR 请求参数为空 --> 抛出请求参数为空的自定义异常 --> 全局异常拦截器 请求参数错误 --> 抛出请求参数错误的自定义异常 --> 全局异常拦截器 ... --> 抛出...的自定义异常 --> 全局异常拦截器 系统出错 --> 抛出运行时异常 --> 全局异常拦截器 全局异常拦截器 --> 统一记录错误日志并返回响应
先前对 AOP 的认知过于狭隘,将其定位为一种针对某项流程(小粒度)的增强技术(外置装甲、ガンダム),在接触了统一封装之后,也感受到了 AOP 在整体流程(大粒度)中的整合作用(统一管理)
统一封装机制实现流程
统一封装
统一封装机制运行流程
异常情况
正常情况
跨域问题
前言
什么是跨域?
浏览器从一个域名的网页去请求另一个域名的资源时,域名、端口、协议任一不同,都是跨域。
为什么会出现跨域问题?
出于浏览器的同源策略限制。
所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host)和端口号(port)
同源策略(Sameoriginpolicy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。同源策略会阻止一个域的 javascript 脚本和另外一个域的内容进行交互。
解决过程
原理
在请求头中加入 Access-Control-Allow-Origin
相关配置信息
实践
最终选用 重写 WebMvcConfigurer 的方式解决了跨域问题
package edu.hitwh.werunassignment.model.request;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfg implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
//设置允许跨域的路径
registry.addMapping("/**")
//设置允许跨域请求的域名
//当**Credentials为true时,**Origin不能为星号,需为具体的ip地址【如果接口不带cookie,ip无需设成具体ip】
.allowedOrigins("*")
//是否允许证书 不再默认开启
.allowCredentials(false)
//设置允许的方法
.allowedMethods("*")
//跨域允许时间
.maxAge(3600);
}
}
已解决问题及其解决过程
漏写注解的奇怪问题
解决方案都很简单,就是补充注解,但是通过漏写注解的 Debug 经历,深刻感受到注解的作用
初见能发现漏写注解,也是件不容易的事情,总结出的经验是如果发现各类简中互联网找不到的奇怪问题,先考虑是不是漏写注解了
漏写@RequestBody
漏写了就不实现 JSON → Java Object 的转换,导致请求处理异常
漏写@Data
本质上是漏 getter 和 setter 方法,漏写了会导致 Spring 无法接管 Java 对象 向 HTTP response 的转换,导致响应处理异常
问题起源:返回格式异常的解决
问题描述:统一封装后出现的报异常的问题
什么接口都报异常?
不报异常的情况
- 登录 → 不返回 JSON
- 获取权限状态 → 不返回 JSON
- 登出 → 不报异常,也不返回 JSON
发现新问题,UserController 和 VoteController 忘记修改成统一封装(没改全)→ 先彻底完成统一封装
封装完成后都是一样的错误了
没有在 @GetMapping 中说明返回类型是 application/json 时,显示不知道该怎么转换
DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation]
在 @GetMapping 中说明返回类型是 application/json 后,显示无法转换
org.springframework.http.converter.HttpMessageNotWritableException: No converter for [class edu.hitwh.werunassignment.common.BaseResponse] with preset Content-Type 'null'
参考文档内容摘录
The Cause
The stack trace of the exception says it all: It tells us that Spring fails to find a suitable HttpMessageConverter capable of converting a Java object into the HTTP response.
Basically, Spring relies on the “Accept” header to detect the media type that it needs to respond with.
So, using a media type with no pre-registered message converter will cause Spring to fail with the exception.
解决思路回顾
- 发现提示不知道如何转换的异常 → 指定转换成 application/json
- 指定转换格式后发现还是转换不了 → 认为是 Converter 的问题,尝试引入 fastjson2
- 引入后还是不行,可能不是 Converter 的问题 → 搜索发现转换的实现依赖于 getter 和 setter,可能是待转换的对象忘记写 getter 和 setter
- 转换对象从 User、Song 全部封装为 BaseResponse,然而 BaseResponse 忘记用 @Data 了(Lombok 注解自动引入 getter 和 setter) → 补上@Data 解决
个人感受
不懂底层真吃亏,框架报异常手足无措,之前并不了解 Spring MVC 这一套自动转换是怎么实现的,就当成黑箱(刚接触时感觉无比便利,简直就是魔法),现在知道这一切都是事出有因,感谢基础设施的实现者,致敬真正的行业先驱
简单总结一下流程:Converter 接管 setter 和 getter 方法,然后实现转换
之前的 Request 和用代码生成器生成的 domain 类都在无意中写上了,所以一开始没遇上什么问题,后面统一封装的时候忘记写了才发现有这样的问题,再次感受到跑通的重要性,虽然代价是耗时
漏写@TableLogic
漏写了 MyBatis Plus 就不接管逻辑删除(假删除),而是直接删除(真删除)
页面展示数据依赖于接口导致的异步问题
参考文档
先前阅读过《JavaScript 百炼成仙》,里面简单介绍了 Promise 的使用方法,还有印象,这里就当实践
解决过程
使用 Promise 解决异步问题
- 用 Promise 对象 包装 用于获取数据的接口
- 利用变量或对象承载接口数据
- 在 then 中处理接口数据
//页面刷新时加载数据
let songList = new Array()
new Promise((resolve) => {
window.onload = function () {
console.log("onload")
$.ajax({
url: url+"/vote/showVotes",
type: 'get',
success(res) {
console.log(res.data)
let list = res.data;
songList = new Array(list.length)
for (let i = 0; i < list.length; i++) {
songList[i] = {songInfo: list[i], isDone: false}
}
resolve(songList)
}
})
}
}).then(songList => {
new Vue({
el: '#student',
data: {
songList: songList
},
methods: {
vote() {
console.log("vote")
},
logout() {
console.log("logout")
$.ajax({
url: url+"/user/logout",
type: 'get',
success(res) {
console.log(res)
window.location.replace("login.html")
}
})
},
setDone(song) {
console.log('setDone')
song.isDone = !song.isDone
console.log("修改后:")
console.log(song)
},
},
})
})
未解决问题及其原因
Session 鉴权
理想状况
用户登录 → 将脱敏数据存到请求对应的 Session 中(存储登录态)
用户登录后请求 → 在请求对应的 Session 中获取数据(查询登录态),通过登录态信息(如角色权限)限制用户操作
实际现象
网页端登录,session 能存登录态,postman 取不到登录态,网页端取不到登录态
postman 登录,session 能存登录态,postman 能取到登录态,网页端取不到登录态
网页端和 postman 的 request 都是 org.apache.catalina.connector.RequestFacade@5756de22
,重启服务器后都是org.apache.catalina.connector.RequestFacade@5f1c1934
,即 网页端 和 postman 的请求对应同一个 request 对象
尝试解决
放弃原因
- 暂时没有想到好的解决方案,甚至连解决问题的方向都找不到,解决问题的成本太高
- 需求分析时将本项目定位为小规模用户量内部系统,风险不高,而且可以通过前端限制页面访问流程的方式,在业务流程层次处理问题
按照个人先前积累的“业务流程能解决的,不交由编码实现解决”的观点,先把鉴权这块给取消了,代价是接口安全性问题
前端打包
理想状况
- 配置 webpack.config.js
- 修改 package.json
- npm install 相关依赖
- 打包
- 在 HTML 中删除旧依赖,引入打包好的新依赖(如 main.js)
- 测试网页,功能正常
实际现象
无法打包 → 打包后功能异常
尝试解决
尝试用 npm install 更多可能依赖,成功打包,但是在 HTML 中删除旧依赖,引入新依赖时,引入的新依赖不能完全替代旧依赖所起的作用
放弃原因
- 打包能通过,实际不能运行,潜在依赖的包多,解决问题的成本太高
- 前端打包目标在于优化依赖、减少体积,提高页面渲染效率,好处有,但是不是必要的,对于本次小型项目而言,打包成本高于打包效益,尝试打包只是为了扩展知识面
部署
ECS 基础环境配置
前言
为什么使用 ECS 而不是虚拟机?
经验储备:大二期间参与过华为的校企合作课程,白嫖过5个月的华为云 ECS ,积累过 ECS 的使用经验
设备基础:寒假期间,学生认证白嫖了7个月的阿里云 ECS,不用白不用,用虚拟机实现原理是一样的,但 ECS 配置起来更方便
需要提供什么样的基础环境?
- Ubuntu:个人用得比较多的 Linux 发行版本
- MySQL:远程数据库
- Docker:跑镜像
实践
基础环境配置其实已经很熟了,这里简单回顾一下流程,这里故意没用宝塔面板,如果引入宝塔面板,Linux 安装软件就像从 App Store 下载软件一样简单
MySQL
流程介绍
-
安装
sudo apt update sudo apt install MySQL-server
-
数据库初始化
ALTER USER 'root'@'localhost' IDENTIFIED BY <new password>; flush privileges; quit;
-
远程访问授权
为方便,设置允许所有 IP 访问,可能存在安全问题-
用户表设置
#前提:本地登录 mysql -uroot -p<password> use mysql; select host,user from user; update user set host='%' where user='root'; select host,user from user; # 确认修改 alter user 'root'@'%' identified with mysql_native_password by <password>; exit #退出重进 mysql -uroot -p<password> use mysql flush privileges; exit
-
配置文件设置
cd /etc/mysql/mysql.conf.d vi mysqld.cnf #将 bind-address 从 127.0.0.1 改成 0.0.0.0 #重启服务 systemctl restart mysql #检查是否成功启动 systemctl status mysql #查 IP 地址 ifconfig #测试是否能过通过 IP 访问 telnet <IP> 3306
-
-
Navicat 实现 MySQL 数据库文件转储
- 原数据库右键转储 sql 文件
- 连接远程数据库,创建新数据
- 在新数据库中创建新数据库表
- 在新数据库表中运行 sql 文件
Docker
流程介绍
-
安装
curl -sSL https://get.daocloud.io/docker | sh
-
Docker hello-world 测试
docker run hello-world
封装镜像
先前都是直接部署,封装镜像这块是第一次做,此处简单总结一下相关理论和实践经历
前言
如何将项目打包成镜像?
使用 Dockerfile
什么是 Dockerfile ?
Dockerfile 是一个用来构建镜像的文本文件,文本内容包含了一条条构建镜像所需的指令和说明。
Dockerfile 的好处都有啥?
可复用性强,一次编写,重复打包(同一类型项目)
项目构建(项目打包)与镜像封装(镜像打包)
项目构建(项目打包)指的是将工程文件构建成规范的包(工程文件 → 包)
项目构建依赖于特定的构建工具
平时用到的 APK、exe 其实也是打包后的产物
镜像封装(镜像打包)指的是将工程文件或将项目构建成的包封装成 Docker 容器
镜像封装依赖于 Docker,而 Docker 只能运行在 Linux 上,开发时用到的跑 Linux 的机器性能比较弱,而且用起来往往比较麻烦,所以有两种做法
- 本地代码本机构建 → 构建包上传 Linux 机器 → Linux 机器将构建包封装成镜像 → Linux 机器运行镜像
- 本地代码推送 Git → Linux 机器用 Git 拉取代码 → Linux 机器用容器化构建工具将代码构建成包,再将构建好的包封装成镜像→ Linux 机器运行镜像
一般来说是这样的,但是现在出现了 Windows Subsystem for Linux 这种东西,相当于将 Linux 作为 Windows 子系统(融合双系统),这使得 Docker 能“直接”跑在 Windows 上,这又产生了一种新的做法
-
本地代码本机构建 → 本地 Docker 将构建包封装成镜像
→ 镜像压缩成 tar 压缩包 → 将 tar 压缩包发送到 Linux 机器 → Linux 机器运行镜像
→ 本地 Docker 跑镜像
ECS 算力低,操作繁,计划采用方法 3,现在本地把大部分工作做完,后面压成 tar 包放 ECS 上跑
Spring Boot Dockerfile 实例
FROM circleci/jdk8:0.1.1 # 拉取现有镜像,指定镜像版本防止出现奇怪错误
COPY target/werun-assignment-0.0.1.jar . # 将本地 target/werun-assignment-0.0.1 复制到镜像的根目录中
CMD ["java","-jar","werun-assignment-0.0.1.jar","--spring.profiles.active=prod"] # 镜像内部跑 java,指定运行环境为生产环境
前端 Dockerfile 实例
FROM nginx
WORKDIR /usr/share/nginx/html
USER root
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY ./dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
实践
后端镜像化
失败的尝试:导出容器
#导出一个已经创建的容器导到一个文件
docker export -o 文件名.tar 容器id
#将文件导入为镜像
docker import 文件名.tar 镜像名:镜像标签
要先run,才能 export ?
docker export -o werun-backend.tar 0b184a6b849b
docker import werun-backend.tar werun-backend:v0.0.1
导出容器用起来有问题?
正确的做法:导出镜像
docker save -o werun-assignment.tar 8ebaf72af6df
docker load < werun-assignment.tar
小插曲:对照上述参考文档执行 save,出现没有名字的问题
解决方法:
-
带名字打包
docker save -o 压缩包名称.tar 容器id 容器名称:版本号
-
创建名字(重命名)
docker tag 容器id 容器名称:版本号
docker tag 8ebaf72af6df werun-backend:v0.0.1
实践经验:改名会新建一个同 id 镜像
总结
save-load 和 export-import 的区别
- docker save保存的是镜像(image),docker export保存的是容器(container);
- docker load用来载入镜像包,docker import用来载入容器包,但两者都会恢复为镜像;
- docker load不能对载入的镜像重命名,而docker import可以为镜像指定新名称。
- docker export导出的镜像文件大小 小于 save保存的镜像文件大小
- docker save 没有丢失镜像的历史,可以回滚到之前的层(layer)。(查看方式:docker images --tree)
docker export 再导入时会丢失镜像所有的历史,所以无法进行回滚操作(docker tag <LAYER ID> <IMAGE NAME>)
镜像与容器的关系
- docker 容器=镜像+可读层
- 镜像和容器 ~ 类和对象
ECS 运行
docker run -p 8080:8080 -d werun-backend:v0.0.1 -g 'daemon off;'
后端 Docker 部署成功 !
注意事项
每 run 同一个 image,都会创造一个 container,这会导致后 run 的容器 host 端口被占用,没法访问
正确做法:run image → 创建一个 container → restart containerWindows 端要先启用 Docker Desktop,才能用 docker,否则会报错:docker daemon is not running
前端镜像化
先尝试本地 Nginx 部署,获取一份可用的 nginx.config,再将 nginx.config 覆盖 Nginx 镜像中的 default.config
Nginx 部署小插曲:静态文件丢失
网页对于静态文件的引入都是通过相对路径引入的,Nginx 部署后却读不到对应的静态文件了
解决方法:通过 alias 实现静态资源挂载
server{
listen 80;
server_name localhost <IP>;
location /{
root html/werun-frontend/html;
index login.html;
}
location /js{
alias html/werun-frontend/js;
}
location /css{
alias html/werun-frontend/css;
}
location /pic{
alias html/werun-frontend/pic;
}
}
Nginx 部署小插曲:网页运行异常
接口访问异常
静态网页能访问到,倒是一部署就出问题了,请求路径会变成 http://localhost:9980/<IP>:8080/user/login
多了前面的 localhost:9980
出乎意料的解决方法:更正了 url 的写法
还是得想办法在部署情况下实现访问,发现是 url 写错了导致的问题,如果 url 写的是相对地址或者是漏写/,会自动补上http://localhost:port
错误写法:http:/ip:port/function(错误的写法,但是被当做相对地址)
正确写法:http://ip:port/function
小技巧:Windows 中 nginx 会检测 nginx.conf 是否改变,如果改变自动更新,不用手动 reload
Docker 镜像运行报错问题的解决
2023-03-11 11:04:13 2023/03/11 03:04:13 [emerg] 1#1: "worker_processes" directive is not allowed here in /etc/nginx/conf.d/default.conf:2
2023-03-11 11:04:13 nginx: [emerg] "worker_processes" directive is not allowed here in /etc/nginx/conf.d/default.conf:2
兜兜转转,修了各种前端 Bug,还是回到最初的问题,既然是 nginx.conf 第2行有问题,不如直接删除该行,删掉后运行镜像不再报错
Docker 镜像运行时,页面异常问题的解决
分两步,第一步是 Dockerfile 的修改,第二步是 nginx.config 的修改
Dockerfile 的修改
本地 nginx 部署路径正确,容器 nginx 中部署路径就无法访问?→ 路径有问题
进入容器查看文件目录,发现少了一层 werun-frontend 的文件夹 → Dockerfile 有问题
COPY werun-frontend /usr/share/nginx/html → COPY werun-frontend /usr/share/nginx/html/werun-frontend
对于 COPY 的语义认识错误,COPY 指的是将该文件夹下所有文件复制到指定位置,而不包括文件夹本身
nginx.config 的修改
相对路径改绝对路径
server{
listen 80;
server_name localhost <IP>;
location /{
root html/werun-frontend/html;
index login.html;
}
location /js{
alias html/werun-frontend/js;
}
location /css{
alias html/werun-frontend/css;
}
location /pic{
alias html/werun-frontend/pic;
}
}
#指定的路径:/etc/nginx/html/werun-frontend/html/login.html(由 nginx.exe 的位置指定)
#实际的路径:/usr/share/nginx/html/werun-frontend (由 dockerfile 指定)
server{
listen 80;
server_name localhost <IP>;
location /{
root /usr/share/nginx/html/werun-frontend/html;
index login.html;
}
location /js{
alias /usr/share/nginx/html/werun-frontend/werun-frontend/js;
}
location /css{
alias /usr/share/nginx/html/werun-frontend/werun-frontend/css;
}
location /pic{
alias /usr/share/nginx/html/werun-frontend/werun-frontend/pic;
}
}
知识点补充
pwd 显示当前路径
docker run -p 80:80 -d werun-frontend:v0.0.1 #-p:将 本机 80 端口映射到 容器 80 端口;-d:后台运行
一定要指定 host 端口向容器端口的映射,否则访问不到
总结
感受
经过本次项目,我学到了哪些内容?
个人对于实践学习的定位一直都是串联知识体系,扩展认知边界。
在串联知识体系的角度看,虽然本次项目还存在未能实现的部分(Session 鉴权、前端打包),但是总体上来说算是完成了实践学习的使命。在本次项目实践之前,个人只是简单了解技术选型中所涉及技术的基本使用方法,并不能建立起对该技术在项目开发过程中所起的作用定位的清晰认知;在完成项目实践之后,个人非但对各种技术的定位建立起了清楚的认知,而且对于一些实操中才能遇见的问题有了更深刻的理解,真正通过实践而不是理论,建立起对全栈开发的全流程认知,成就感满满。
在扩展认知边界的角度看,个人在本次实践项目之前就已经了解 Bootstrap 的使用、Vue 的使用、jQuery 的使用、SSM 架构的使用、ECS 环境配置、Docker 的使用,并尝试实现过统一封装和解决跨域问题,下面简单汇总一下新掌握的知识
- Nginx 部署流程认知建立
- Webpack 打包流程认知建立
- Dockerfile 镜像封装流程认知建立
- YApi 自动化生成接口文档流程认知建立
优化方向
业务设计方面的讨论
需求中给出了一首歌曲播放一次后就删除的背景,又要求实现修改歌曲播出状态和删除歌曲两种功能,个人认为这不符合 MECE 法则,所以将修改歌曲的播出状态的需求和删除歌曲的需求合并,只保留删除歌曲的功能,同时只提供接口删除,不为其提供专门页面
页面优化和 Bug 修改
本项目页面实现以基本功能正常为目标,没有考虑页面美观和易用程度的问题,后续可以在这方面做优化
运行过程中可能会发现开发过程中没注意到的 Bug
源码地址
前端
https://github.com/Ba11ooner/song-on-demand-platform-frontend
后端
https://github.com/Ba11ooner/song-on-demand-platform-backend