项目架构解释说明
模块概览
base-service
1 | base-service |
common-service
1 | base-support/common-service |
facade-service
1 | base-support/facade-service |
主要功能
拦截鉴权— ActionInterceptor
本项目中鉴定登录状态的方式为:在请求头部加入token
,然后在拦截器ActionInterceptor.java
中,从请求头部中取出token
,并依据token
来获取userId
,然后将userId
插入到头部中;如果上述过程,出现token
是null
、userId
是null
,那么该请求将被视为非登录状态,将不会传递到Controller层。
如何跳过校验?
在Controller的方法上,加上@AnonymousSupport
注解,在ActionInterceptor.java
中,会通过Method
方法,获取@AnonymousSupport
,如果存在就不进行后面的登录状态校验。
1 | HandlerMethod handlerMethod = (HandlerMethod) o; |
如何将userId
添加到请求的headers
中?
通过token
在Redis中获取到userId
后,如何在headers
中添加userId
键值对,略微繁杂,但是目的很单纯。设置值的代码如下所示:
1 | MimeHeaders mimeHeaders = (MimeHeaders) headers.get(coyoteRequest); |
在前面,有一个对request
类型进行判断的语句,主要目的是为了避免NullPointerException
,因为后面通过反射获取属性的时候,可能会由于request
类型不同,而获取不到对应的Field
,从而导致出现异常。
1 | if (request instanceof StandardMultipartHttpServletRequest) { |
请求日志
主要的任务是将请求所有的参数(如:url中的参数、方法、headers、requestBody等)都以直观的方式打印出来。它的主要流程有两处:
拦截器
TraceCopyFilter
初始化RequestWrapper
时,将请求所有的信息都保存到HttpTraceLog
中,并通过LogTrace
保存在当前线程中(利用ThreadLocal
)。1
2
3
4
5
6
7LogTrace.get().setStartTime(System.currentTimeMillis());
LogTrace.get().setHttpMethod(request.getMethod());
LogTrace.get().setUrl(requestURI);
LogTrace.get().setReqParams(request.getQueryString());
LogTrace.get().setReqHeader(getHeaderMap(request));
// ...
LogTrace.get().setRequestBody(sb.toString());在
ResponseAdvice
包装完返回信息之后,会在finally中将所有的请求信息,通过log的形式打印出来。打印完成后,会通过LogTrace
将请求的信息从本线程中移除掉(利用ThreadLocal
)。1
2
3
4
5
6
7
8
9try {
LogTrace.get().setSpendTime(System.currentTimeMillis() - LogTrace.get().getStartTime());
LogTrace.get().setRespParams(objectMapper.writeValueAsString(result));
log.info("Trace log is ====> " + objectMapper.writeValueAsString(LogTrace.get()));
} catch (Exception e) {
log.error("Trace log error : ", e);
} finally {
LogTrace.clearAll();
}
其中获取body时,直接使用IO流,把数据保存到变量requestBody
中,代码如下:
1 | // request == null 是一个标志位 |
但是收到POST请求时(参考链接),会产生两个TCP数据包(并不是所有浏览器都会在POST中发送两次包,Firefox就只发送一次),即浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。所以在拿POST请求中body时,如果直接读出来,可能导致后面框架再去读的时候,出现读不了的情况,所以在RequestWrapper
中,重写了getInputStream
方法,在request
不为空,即没有读取过body(这种情况就是在上传文件的情况),直接以requestBody
作为输入流,提供给框架读取,上述问题便解决了。代码如下:
1 |
|
SQL日志
SqlLogHandler
在SQL拦截中进行调用,它主要做的工作是把SQL中的?替换成实际的值,并打印出执行时间。
API统一的返回数据
在ResponseAdvice
中,会将数据都转化成JsonView
的形式。
其中有一个问题,就是当返回的类型是String
的时候,不能包装String类型,只能以String的形式返回。这是由于整个SpringMVC框架的设计问题。假设有如下业务代码:
1 |
|
这时候的返回值如下:
因为是以String的类型直接返回了,上述的返回格式也是理所当然。但是如果将String包装成JsonView
,然后返回会怎么样?修改ResponseAdvice
如下:
1 | if (o instanceof JsonView ) { |
这时候如果再次访问,程序会报错如下:
报错的堆栈信息如下:
1 | java.lang.ClassCastException: com.haylion.realTimeBus.facade.view.JsonView cannot be cast to java.base/java.lang.String |
上面的错误简单理解,类型转换错误,也就是需要一个String,但是却收到了一个JsonView
。需要的String是我们在Controller中返回的类型,然而实际收到的JsonView
是在ResponseAdvice
中包装后返回的。为什么这样的原因是:与ResponseAdvice
执行的时机有关。在AbstractMessageConverterMethodProcessor.java
文件的writeWithMessageConverters()
方法中,调试数据如下:
确定返回类型:
确定可用的转换器,然后执行ResponseAdvice
:
在ResponseAdvice
执行前,SpringMVC会根据Controller的返回类型,确定一个AbstractHttpMessageConverter
,由于在Controller中返回类型为String,所以这里为StringHttpMessageConverter
,也就是说,它是用来转换一个String类型的转换器。等转换器确定好了之后,会执行ResponseAdvice中的处理方法,将String转换成JsonView
。
写入返回数据:
忽略掉其他代码,直接进入出现错误的代码,在AbstractHttpMessageConverter
中的addDefaultHeaders()
方法中,需要在头部获取整个请求的大小,即调用getContentLength()
方法。
它是一个期望被子类覆盖的方法,默认的实现如下:
1 | protected Long getContentLength(T t, MediaType contentType)throws IOException { |
这时候应该直接看StringHttpMessageConverter
中的getContentLength()
方法如下:
1 |
|
然后再将转换后的JsonView
作为抽象函数getContentLength()
(这时就是StringHttpMessageConverter
的该函数)的第一个参数,如下:
1 | protected Long getContentLength(String str, { MediaType contentType) |
第一个参数为String,但是实际上是JsonView
。因此,ClassCastException
在所难免。在ResponseAdvice
中,将String直接返回,可以避免出现这种不太好修复的错误。
替代办法:
但是如果非要返回String类型,并且需要包装成JsonView
形式,可以考虑直接在Controller中将String包装成JsonView
,然后返回,如下:
1 |
|
结果:
异常处理
参看ExceptionHandle
具体实现及写法、以及相关源码注释。
分页–PageHelper
用法
直接在方法上加上@PageAble
注解,并在该方法中传入两个参数,分别为page
和size
,在该方法返回后,会得到一个ResultPageView
封装对象,其中包含分页相关信息。
工作流程
SystemAdvice
定义一个切面,切点是@annotation(com.haylion.realTimeBus.annotation.PageAble)
。也就是说,每个被@PageAble
注解过的方法,都将执行下面的代码:
1 | private static final String PAGE_ABLE = "@annotation(com.haylion.realTimeBus.annotation.PageAble)"; |
准备工作:主要是获取page
和size
的值,然后调用PageHelper
的startPage
方法,初始化分页信息。
1 | // PageAble中page和size的默认值分别是1和20 |
进入SQL拦截器(即MyPageInterceptor
):这个拦截器中主要是PageHelper执行分页的步骤,相关步骤可分为:
判断是否需要进行分页。判断的条件为
!dialect.skip(ms, parameter, rowBounds)
,其实现为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
if(ms.getId().endsWith(MSUtils.COUNT)){
throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
}
Page page = pageParams.getPage(parameterObject, rowBounds);
if (page == null) {
return true;
} else {
//设置默认的 count 列
if(StringUtil.isEmpty(page.getCountColumn())){
page.setCountColumn(pageParams.getCountColumn());
}
autoDialect.initDelegateDialect(ms);
return false;
}
}也就是说,通过判断
Page
是否为空来决定是否进行分页,Page
则从本线程中获取,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14// PageHelper.java
Page page = pageParams.getPage(parameterObject, rowBounds);
//PageParams.java
public Page getPage(Object parameterObject, RowBounds rowBounds) {
Page page = PageHelper.getLocalPage();
...
}
// PageMethod.java
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();获取数据的总条数。在进入此项前,会进行判断是否需要进行总数查询。这里假设进行总数查询。从源SQL解析出获取数据总条数的代码调试如下:
log如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
132019-06-14 09:37:31.475 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Preparing: SELECT count(0) FROM advertising
2019-06-14 09:37:31.490 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Parameters:
2019-06-14 09:37:31.507 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - <== Total: 1
2019-06-14 09:37:31.508 INFO [http-nio-8880-exec-1] c.h.r.i.s.SqlLogHandler - com.haylion.realTimeBus.dao.AdvertisingMapper.getByConditionList_COUNT:
select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising
<cost time is :45 ms >
2019-06-14 09:37:31.512 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Preparing: select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising LIMIT ?
2019-06-14 09:37:31.512 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - ==> Parameters: 1(Integer)
2019-06-14 09:37:31.519 DEBUG [http-nio-8880-exec-1] o.a.i.l.j.BaseJdbcLogger - <== Total: 1
2019-06-14 09:37:31.520 INFO [http-nio-8880-exec-1] c.h.r.i.s.SqlLogHandler - com.haylion.realTimeBus.dao.AdvertisingMapper.getByConditionList:
select id, advertising_name, advertising_start_time, advertising_end_time, advertising_position, images_url, advertiser_url, advertiser_name, advertiser_id, settlement_type, settlement_price, create_time, create_user, audit_status, audit_opinion, audit_time, advertising_type from advertising LIMIT 1
<cost time is :8 ms >
2019-06-14 09:37:31.591 INFO [http-nio-8880-exec-1] c.h.r.f.a.ResponseAdvice - Trace log is ====> {"url":"/advertising/getAdvertisingList","httpMethod":"GET","reqHeader":{"host":"192.168.12.39:8880","content-type":"application/json","user-agent":"curl/7.54.0","accept":"*/*","token":"fe20027352f8250571436f471a988b4d"},"reqParams":"page=1&size=1","requestBody":"","respParams":"{\"code\":200,\"message\":\"success\",\"data\":{\"total\":9,\"current\":1,\"pageCount\":9,\"list\":[{\"settlementType\":0,\"imagesUrl\":\"xxxxxxx\",\"advertisingName\":\"hello kitty 111\",\"advertiserName\":\"暁\",\"advertiserId\":0,\"createTimeymdhm_Str\":\"2019-06-10 17:27\",\"advertisingType\":0,\"createTime\":1560158854000,\"advertisingPosition\":0,\"auditStatus\":4,\"createUser\":1,\"id\":0,\"advertiserUrl\":\"xxxxx\",\"createTimeStr\":\"2019-06-10 17:27:34\"}]}}","startTime":1560476250978,"spendTime":592}获取完总数后,会进行判断是否有分页的必要。
分页查询。这里假设有分页的必要。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19//调用方言获取分页 sql
String pageSql = dialect.getPageSql(ms, boundSql, parameter, rowBounds, pageKey);
public String getPageSql(MappedStatement ms, BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
String sql = boundSql.getSql();
Page page = getLocalPage();
//支持 order by
String orderBy = page.getOrderBy();
if (StringUtil.isNotEmpty(orderBy)) {
pageKey.update(orderBy);
sql = OrderByParser.converToOrderBySql(sql, orderBy);
}
if (page.isOrderByOnly()) {
return sql;
}
// 这是一个抽象方法,会根据具体的数据库,调用不同的实现方法,来在原SQL语句上,加上对应的分页语句
return getPageSql(sql, page, pageKey);
}具体支持的数据库如下:
Oracle的分页实现如下:
1
2
3
4
5
6
7
8
9
10
11//
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 120);
sqlBuilder.append("SELECT * FROM ( ");
sqlBuilder.append(" SELECT TMP_PAGE.*, ROWNUM ROW_ID FROM ( ");
sqlBuilder.append(sql);
sqlBuilder.append(" ) TMP_PAGE WHERE ROWNUM <= ? ");
sqlBuilder.append(" ) WHERE ROW_ID > ? ");
return sqlBuilder.toString();
}MySQL的分页实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
public String getPageSql(String sql, Page page, CacheKey pageKey) {
StringBuilder sqlBuilder = new StringBuilder(sql.length() + 14);
sqlBuilder.append(sql);
if (page.getStartRow() == 0) {
sqlBuilder.append(" LIMIT ? ");
} else {
sqlBuilder.append(" LIMIT ?, ? ");
}
pageKey.update(page.getPageSize());
return sqlBuilder.toString();
}保存分页查询后的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32// resultList是分页查询后的数据列表
// afterPage的返回值是有两种情况,但是都可以被转成List
return dialect.afterPage(resultList, parameter, rowBounds);
// dialect.afterPage()方法
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if(delegate != null){
return delegate.afterPage(pageList, parameterObject, rowBounds);
}
return pageList;
}
// delegate.afterPage()方法
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
Page page = getLocalPage();
if (page == null) {
return pageList;
}
page.addAll(pageList);
if (!page.isCount()) {
page.setTotal(-1);
} else if ((page.getPageSizeZero() != null && page.getPageSizeZero()) && page.getPageSize() == 0) {
page.setTotal(pageList.size());
} else if(page.isOrderByOnly()){
page.setTotal(pageList.size());
}
return page;
}其实这里有一个问题是,如果delegate不为空,那么返回的是
Page
,但是我们在调用xxxxxMapper
的查询方法之后,返回值基本上是List
,与我们的常识并不符合。那Page
是什么呢?它不只是包含分页信息的基本类,它继承自ArrayList。1
2
3public class Page<E> extends ArrayList<E> implements Closeable {
// ...
}在return后,还会执行finally中的处理代码,即
com.haylion.realTimeBus.interceptor.sql.MyPageHelper
的afterAll()
方法。其中实现如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// com.haylion.realTimeBus.interceptor.sql.MyPageHelper.afterAll()
// 这个方法是我们自定义的方法,用来处理执行完前面所述的切点后,保留分页信息,进行再次封装
public void afterAll() {
Page<Object> localPage = getLocalPage();
// 删除分页信息
super.afterAll();
// 设置回本线程中
setLocalPage(localPage);
}
// super.afterAll()。这个方法可以简单理解成,清楚掉本线程中的分页信息
public void afterAll() {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if (delegate != null) {
delegate.afterAll();
autoDialect.clearDelegate();
}
clearPage();
}
// 移除本地变量
public static void clearPage() {
LOCAL_PAGE.remove();
}经过上述的过程,
MyPageInterceptor
执行完毕,分页信息存储在本线程中,然后回到切面处理。
切面收尾工作(回到SystemAdvice
):
1 | private Object after(Object obj) { |
至此,还有最重要的一个步骤,是在切面处理完成后,将分页信息从本线程中删除,没有此操作,后续操作会出现莫名其妙的错误。也就是finally语句中的PageHelper.clearPage();
。
1 | try { |
局限
这就限定了在一个被PageAble
注解了的方法上,只能执行一条查询。如果对于一个到来的请求,需要进行两次或以上的查询,并且某一条查询需要分页的情况,如果所有的查询都放在被PageAble
注解的方法下,执行会出现问题(出现不必要的分页操作)。但是可以通过组装的形式,完成该项需求。