真实记录一次对项目中代码的重构过程

这份记录来自对项目中下单接口重构,详细记录了每一步操作、以及运用到的一些方法。力求能够最大程度将当时的过程展现出来。准备重构中的每次修改都需要进行测试,用来验证修改是否正确,因此单元测试是一个非常好的选择。单元测试单元测试中可以进行mock操作,从烦人的token中摆脱出来,做到在任何人的IDE里面
阅读更多

云主机如何应对大批量的漏洞测试请求

云主测试工具在做测试,通过日志发现,其搜寻的漏洞内容包括:SQL注入、XSS等。由于其请求的频率过高,以致于nginx做转发出现502错误,造成了宕机的假象。正对这种情况,想到了以下两种处理方式。wordpress拒绝请
阅读更多

理解Servlet与Filter的关系与设计思路

什么是Servlet对一个HTTP请求的正常的处理流程是:发送HTTP请求服务端的HTTP服务器收到请求调用业务逻辑返回HTTP响应产生了下面3个问题:HTTP 服务器怎么知道要调用哪个业务逻辑,也就是 Java 类的哪个方法呢?HTTP服务器可以被设计成收到请求后,接续寻找该请求的处理逻辑,但是这
阅读更多

处理后端与Android之间WebSocket连接经常断开的情况

nginx配置ws转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
location ~ /(mq|ws)/ {
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 600s;
proxy_send_timeout 600s;

proxy_pass http://mq-service;
}

添加头部信息,这两个字段表示请求服务器升级协议为WebSocket:

1
2
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

默认情况下,连接将会在无数据传输60秒后关闭,proxy_read_timeout参数可以延长这个时间。源站通过定期发送ping帧以保持连接并确认连接是否还在使用。

  • proxy_read_timeout

    该指令设置与代理服务器的读超时时间。它决定了nginx会等待多长时间来获得请求的响应。 这个时间不是获得整个response的时间,而是两次reading操作的时间。

  • proxy_send_timeout

    这个指定设置了发送请求给upstream服务器的超时时间。超时设置不是为了整个发送期间,而是在两次write操作期间。 如果超时后,upstream没有收到新的数据,nginx会关闭连接

Android/微信小程序心跳机制

  1. 定时发送心跳包。如果发送失败,就进行重连。
  2. 一些关键的操作,可以在重连后,根据实际情况,立刻进行调用

参考:
https://www.xncoding.com/2018/03/12/fullstack/nginx-websocket.html

WebSocket多实例部署时的一种解决方案

需要用到k8s进行扩展,在变更容器数量的时候,希望达到不改动代码。

遇到的问题

  1. Client与哪一个WS服务建立连接是不知道的
  2. 当需要发送WS消息时,使用URL发送给所有的WS模块不可取(一旦容器数量改变,还需要修改代码,即增加新的URL)

架构图

代码

建立连接部分

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
@ServerEndpoint(value = "/ws/{role}/{token}", configurator = EndpointConf.class)
@Component
@Slf4j
public class WsController {

private static final String PARAM_TOKEN = "token";
private static final String PARAM_ROLE = "role";
private static final Set<String> ROLE_SET = new HashSet<>(
Arrays.asList(AccountType.DRIVER.name().toLowerCase(), AccountType.PASSENGER.name().toLowerCase())
);

@Autowired
private WsService wsService;

@OnOpen
public void onOpen(@PathParam(PARAM_ROLE) String role,
@PathParam(PARAM_TOKEN) String token, Session session) throws IOException {
if (!ROLE_SET.contains(role)) {
// 登陆类型不正确
log.warn("token:{} login role error, role:{}", token, role);
wsService.sendMessage(session, wsService.authFailMsg());
session.close();
return;
}

int userId = wsService.getUserIdByToken(role, token);
if (userId == -1) {
// 根据token找不到userId
log.warn("token:{} login error, you are offline", token);
wsService.sessionMap.remove(token);
wsService.sendMessage(session, wsService.authFailMsg());
session.close();
return;
}
log.info("【{}】, token : {} open websocket connect", wsService.showInfoAboutToken(token), token);

// 删除此token已有session
Session oldSession = wsService.sessionMap.get(token);
if (oldSession != null) {
wsService.sessionMap.remove(token);
wsService.sendMessage(oldSession, wsService.duplicateLoginMsg());
oldSession.close();
}
wsService.sessionMap.put(token, session);
}

@OnClose
public void onClose(@PathParam(PARAM_ROLE) String role,
@PathParam(PARAM_TOKEN) String token, Session session) {
log.info("close connection. 【{}】, token: {}", wsService.showInfoAboutToken(token), token);
wsService.sessionMap.remove(token);
wsService.sendMessage(session, wsService.authFailMsg());
}

@OnError
public void onError(@PathParam(PARAM_ROLE) String role,
@PathParam(PARAM_TOKEN) String token, Session session, Throwable error) {
log.error("【{}】, token : {}, sessionId: {}, websocket error: {}", wsService.showInfoAboutToken(token), token, session.getId(), error);
}

@OnMessage
public void onMessage(@PathParam(PARAM_ROLE) String role,
@PathParam(PARAM_TOKEN) String token, String message, Session session) throws IOException {
log.info("receive from 【{}】, token : {}, message: {}",wsService.showInfoAboutToken(token), token, message);
if (!ROLE_SET.contains(role)) {
// 登陆类型不正确
wsService.sendMessage(session, wsService.authFailMsg());
session.close();
}
//司机心跳缓存
if(role.equals(AccountType.DRIVER.name().toLowerCase())){
wsService.updateHeartBeat(token);
}
wsService.actionHandle(session, message);
}
}

接收各个模块发送WS的MQ消息

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
@Slf4j
public class WsMqMsgListener implements MessageListener {

@Autowired
private WsService wsService;

@Override
public Action consume(Message message, ConsumeContext context) {
log.info("receive tag:{}, body:{}", message.getTag(), new String(message.getBody()));
try {
//消息体执行内容
String bodyStr = new String(message.getBody());

if (StringUtils.isEmpty(bodyStr))
return Action.CommitMessage;

JSONObject body = JSONObject.parseObject(bodyStr);
log.info("got a websocket mq msg");
WebSocketMqMsg.Body wsMqBody = JSON.toJavaObject(body, WebSocketMqMsg.Body.class);
wsService.sendMessage(wsMqBody);

return Action.CommitMessage;
} catch (Exception e) {
e.printStackTrace();
log.error("消费MQ消息失败,原因是:{}", e.getMessage());
//消费失败
return Action.ReconsumeLater;
}
}
}

收到各个模块的MQ消息后,提取出发送对象、发送内容,然后进行发送。如果没有找到对应客户端的连接,那么将抛弃掉该WS消息。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public Map<String, Session> sessionMap = new ConcurrentHashMap<>();
public void sendMessage(WebSocketMqMsg.Body message) {
if (message.getIds().size() > 0) {
for (Integer id : message.getIds()) {
cachedThreadPool.execute(() -> {
int maxIdx = message.getRole().equals(AccountType.PASSENGER.name()) ? 2 : 1;
for (int i = 1; i <= maxIdx; i++) {

String key = String.format(Constants.CACHE_USER_TOKEN_LOGIN_PREFIX,
message.getRole(),
LoginType.valueOf((short) i), id);
log.info("key is {}", key);

String token = cacheService.getVal(key);
log.info("token is {}", token == null ? "null" : token);

boolean sendOfflineMsg = false;
if (!StringUtils.isEmpty(token)) {
Session session = sessionMap.get(token);
log.info("session is {}", session == null ? "null" : "not null");
if (session == null || !sendMessage(session,
message.getMsg().toString())) {
sendOfflineMsg = true;
}
} else {
sendOfflineMsg = true;
}
log.info("ws msg: role -> 【{}】, id -> 【{}】, terminal -> 【{}】, status -> 【{}】",
message.getRole(),
id,
i == 1 ? LoginType.APP.name() : LoginType.WECHAT_APPLET.name(),
sendOfflineMsg ? "offline" : "online");
}
});
}
}
}

public boolean sendMessage(Session session, String message) {
session.getBasicRemote().sendPong();
if (!session.isOpen()) {
return false;
}
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
log.error("send message to {} error {}", session.getId(), e);
return false;
}
return true;
}

后记

按照上述架构完成的多实例WS服务部署,可以解决前面提到的两个问题。MQ作为一个中间这的角色,发挥出了它的作用。

PageAble分页注解在并发环境下遇到的bug

数据库结构及数据说明

结构

结构

数据

数据

对应类

对应类

接口详细说明

获取分页数据接口

1
2
3
4
5
6
7
8
9
10
11
12
13
@AnonymousSupport
@GetMapping("get-system-busy")
public Object getPageableUsers(@RequestParam Integer page, @RequestParam Integer size) {
return testService.getSystemBusy(page, size);
}

@PageAble
public Object getSystemBusy(Integer page, Integer size) {
return testMapper.getSystemBusy();
}

@Select("select gender from user")
List<User> getSystemBusy();

根据id获取用户详情

1
2
3
4
5
6
7
8
9
10
11
12
@AnonymousSupport
@GetMapping("get-one-user")
public Object getOneUserById(@RequestParam Integer id) {
return testService.getOneUser(id);
}

public Object getOneUser(Integer id) {
return testMapper.getOneUser(id);
}

@Select("select * from user where id = #{id}")
User getOneUser(Integer id);

获取所有司机列表

1
2
3
4
5
6
7
8
9
10
11
12
@AnonymousSupport
@GetMapping("get-all-drivers")
public Object getAllDrivers() {
return testService.getAllDrivers();
}

public Object getAllDrivers() {
return testMapper.getAllDrivers();
}

@Select("select * from driver")
List<Driver> getAllDrivers();

获取所有用户的名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@AnonymousSupport
@GetMapping("get-username-list")
public Object getUsernameList() {
return testService.getUsernameList();
}

public Object getUsernameList() {
List<User> users = testMapper.getAllUsers();
List<String> names = new ArrayList<>();
for (User user : users) {
names.add(user.getUsername());
}
return names;
}

@Select("select * from user")
List<User> getAllUsers();

分页相关文件说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
base-service
├── pom.xml
└── src
└── main
└── java
└── com
└── haylion
└── realTimeBus
├── advice
│ └── SystemAdvice.java # 分页注解实现
├── annotation
│ └── PageAble.java # 分页注解
├── bean
│ ├── BaseModel.java # 含有id熟悉的基础bean
│ ├── Condition.java # 条件查询时的条件
│ ├── ResultPageView.java # 被分页注解标记后,改函数返回的对象,将被包装成该对象(一个普通的bean类)
│ └── Sort.java # 查询时排序方式
├── interceptor
└── sql
├── MyPageHelper.java # 覆盖afterAll后的PageHelper,在MyPageInterceptor中进行调用
├── MyPageInterceptor.java # PageHelper中拦截器PageInterceptor的源码,有自定义修改
└── SqlLogHandler.java # 把将要执行的SQL打印出来,集成在MyPageInterceptor中

前言

这个注解主要是对PageHelper插件的封装,这个插件的工作流程可参考:此链接

需要做的是在mybatis的配置文件中加入一个拦截器(拦截器的源代码链接)。MyBatis在执行query语句时,会触发该拦截器,然后得到的数据是处理过的分页后的数据。

在上述链接中有一个注意事项,如下:

什么时候会导致不安全的分页?

PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。
只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelperfinally 代码段中自动清除了 ThreadLocal 存储的对象。
如果代码在进入 Executor 前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement 时),
这种情况由于线程不可用,也不会导致 ThreadLocal 参数被错误的使用。
但是如果你写出下面这样的代码,就是不安全的用法:

1
2
3
4
5
6
7
PageHelper.startPage(1, 10);
List<Country> list;
if(param1 != null){
list = countryMapper.selectIf(param1);
} else {
list = new ArrayList<Country>();
}

这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。
上面这个代码,应该写成下面这个样子:

1
2
3
4
5
6
7
List<Country> list;
if(param1 != null){
PageHelper.startPage(1, 10);
list = countryMapper.selectIf(param1);
} else {
list = new ArrayList<Country>();
}

这种写法就能保证安全。
如果你对此不放心,你可以手动清理 ThreadLocal 存储的分页参数,可以像下面这样使用:

1
2
3
4
5
6
7
8
9
10
11
List<Country> list;
if(param1 != null){
PageHelper.startPage(1, 10);
try{
list = countryMapper.selectAll();
} finally {
PageHelper.clearPage();
}
} else {
list = new ArrayList<Country>();
}

这么写很不好看,而且没有必要。

用法

直接在方法上加上@PageAble注解,并在该方法中传入两个参数,分别为pagesize,在该方法返回后,会得到一个ResultPageView封装对象,其中包含分页相关信息。

工作流程

流程图
SystemAdvice定义一个切面,切点是@annotation(com.haylion.realTimeBus.annotation.PageAble)。也就是说,每个被@PageAble注解过的方法,都将执行下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final String PAGE_ABLE = "@annotation(com.haylion.realTimeBus.annotation.PageAble)";

@Around(PAGE_ABLE)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
logger.info("execute method : " + proceedingJoinPoint.getSignature().getName());
try {
// 进入被@PageAble注解的方法前的准备工作
prepare(proceedingJoinPoint);
// 执行被@PageAble注解的方法
Object obj = proceedingJoinPoint.proceed();
// 执行被@PageAble注解的方法后,执行扫尾工作
Object result = after(obj);
return result;
} catch (Throwable throwable) {
logger.error("aspect execute error : ", throwable);
throw throwable;
}
}

准备工作:主要是获取pagesize的值,然后调用PageHelperstartPage方法,初始化分页信息。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// PageAble中page和size的默认值分别是1和20
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageAble {
String pageSizeName() default "size";
String pageNumName() default "page";
int pageSize() default 20;
int pageNum() default 1;
}
// 准备分页
private void prepare(ProceedingJoinPoint point) throws Exception {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
PageAble pageAble = targetMethod.getAnnotation(PageAble.class);
String numName = pageAble.pageNumName();
String sizeName = pageAble.pageSizeName();
// 先获取默认的page和size值
int pageNo = pageAble.pageNum();
int pageSize = pageAble.pageSize();
Object[] paramValues = point.getArgs();
String[] paramNames = methodSignature.getParameterNames();
int length = paramNames.length;
// 遍历该方法中的所有参数,如果有page和size信息,那么就覆盖默认值为用户传入的值
for (int i = 0; i < length; i++) {
if (paramNames[i].equals(numName)) {
pageNo = (Integer) paramValues[i];
} else if (paramNames[i].equals(sizeName)) {
pageSize = (Integer) paramValues[i];
}
}
// 该方法利用ThreadLocal在本线程中插入一个分页信息的对象Page
PageHelper.startPage(pageNo, pageSize);
}
// startPage()方法的最终实现
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}

protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

进入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
    @Override
    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
    13
    2019-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);

    @Override
    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
    //
    @Override
    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
    @Override
    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()方法
    @Override
    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()方法
    @Override
    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
    3
    public class Page<E> extends ArrayList<E> implements Closeable {
    // ...
    }

    在return后,还会执行finally中的处理代码,即com.haylion.realTimeBus.interceptor.sql.MyPageHelperafterAll()方法。其中实现如下:

    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()
    // 这个方法是我们自定义的方法,用来处理执行完前面所述的切点后,保留分页信息,进行再次封装
    @Override
    public void afterAll() {
    Page<Object> localPage = getLocalPage();
    // 删除分页信息
    super.afterAll();
    // 设置回本线程中
    setLocalPage(localPage);
    }

    // super.afterAll()。这个方法可以简单理解成,清楚掉本线程中的分页信息
    @Override
    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
2
3
4
5
6
7
8
9
10
11
12
13
private Object after(Object obj) {
// ...
PageInfo<?> pageInfo;
Page<Object> localPage = PageHelper.getLocalPage();
long total = localPage.getTotal();
int pageNum = localPage.getPageNum();
int pages = localPage.getPages();
List<?> list = (List<?>) obj;
// ...
pageInfo = new PageInfo((list));
ResultPageView<?> resultPageView = new ResultPageView<>(total, pageNum, pages, pageInfo.getList());
return resultPageView;
}

至此,备注解方法将返回ResultPageView对象,经过包装后,也就是我们常看到的分页格式:

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "success",
"data": {
"total": 17,
"current": 1,
"pageCount": 9,
"list": []
}
}

局限

这就限定了在一个被PageAble注解了的方法上,只能执行一条查询。如果对于一个到来的请求,需要进行两次或以上的查询,并且某一条查询需要分页的情况,如果所有的查询都放在被PageAble注解的方法下,执行会出现问题(出现不必要的分页操作)。但是可以通过组装的形式,完成该项需求。

bug说明

  1. 当一条线程,执行被PageAble注解过的方法时,线程中会保存Page信息。

  2. 如果切面没有执行完,会导致Page在处理完改请求后,继续留存在该线程中。

  3. 当一条含有Page对象的线程,处理某个不分页、但需进行查询的请求时,会导致该查询进行分页,并且会将Page对象中的之前查询得到的数据一并返回。

四个请求的各自功能

  1. 在线程中留下分页标志。 curl -s localhost:8880/test/get-system-busy\?page=1\&size=2 | jq --indent 4

  2. 在分页后的数据列表中,加入数据。curl -s localhost:8880/test/get-all-drivers | jq --indent 4

  3. 出错情况1:TooManyResultsException。curl -s localhost:8880/test/get-one-user\?id=1 | jq --indent 4

  4. 出错情况2 & 3:ClassCastException & 数据累积。curl -s localhost:8880/test/get-username-list | jq --indent 4

PPT内容

百度地图墨卡托坐标转高德经纬度坐标(偏移小)

参考:http://www.site-digger.com/tools/mct2latlng.html

这里的转换是直接调用百度地图SDK中的API,通过对其中JavaScript源代码的执行跟踪,提取出其中的墨卡托坐标转百度经纬度坐标的代码如下:

Java版本:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Data
public static class Point {
private double lng, lat;

public Point(double lng, double lat) {
this.lng = lng;
this.lat = lat;
}

@Override
public String toString() {
return lng + "," + lat;
}
}

private static double xPi = 3.14159265358979324 * 3000.0 / 180.0;

// 以下参数来自百度地图的SDK
private static double[] MCBAND = {12890594.86, 8362377.87, 5591021, 3481989.83, 1678043.12, 0};

private static double[][] MC2LL = {
{1.410526172116255e-8, 0.00000898305509648872, -1.9939833816331, 200.9824383106796, -187.2403703815547, 91.6087516669843, -23.38765649603339, 2.57121317296198, -0.03801003308653, 17337981.2},
{-7.435856389565537e-9, 0.000008983055097726239, -0.78625201886289, 96.32687599759846, -1.85204757529826, -59.36935905485877, 47.40033549296737, -16.50741931063887, 2.28786674699375, 10260144.86},
{-3.030883460898826e-8, 0.00000898305509983578, 0.30071316287616, 59.74293618442277, 7.357984074871, -25.38371002664745, 13.45380521110908, -3.29883767235584, 0.32710905363475, 6856817.37},
{-1.981981304930552e-8, 0.000008983055099779535, 0.03278182852591, 40.31678527705744, 0.65659298677277, -4.44255534477492, 0.85341911805263, 0.12923347998204, -0.04625736007561, 4482777.06},
{3.09191371068437e-9, 0.000008983055096812155, 0.00006995724062, 23.10934304144901, -0.00023663490511, -0.6321817810242, -0.00663494467273, 0.03430082397953, -0.00466043876332, 2555164.4},
{2.890871144776878e-9, 0.000008983055095805407, -3.068298e-8, 7.47137025468032, -0.00000353937994, -0.02145144861037, -0.00001234426596, 0.00010322952773, -0.00000323890364, 826088.5}
};

/**
* 参考:http://www.site-digger.com/tools/mct2latlng.html
* 对上述链接中的操作,找到百度地图的SDK的源代码,然后转换成Java
*/
private static Point convertMC2LL(Point mp) {
Point absPoint = new Point(abs(mp.getLng()), abs(mp.getLat()));
double[] paramArr = null;
for (int i = 0; i < MCBAND.length; i++) {
if (absPoint.getLat() >= MCBAND[i]) {
paramArr = MC2LL[i];
break;
}
}

if (mp == null || paramArr == null)
throw new RuntimeException("转换出错");

double lng = paramArr[0] + paramArr[1] * abs(mp.getLng());
double tlat = abs(mp.getLat()) / paramArr[9];
double lat = paramArr[2]
+ paramArr[3] * tlat
+ paramArr[4] * tlat * tlat
+ paramArr[5] * tlat * tlat * tlat
+ paramArr[6] * tlat * tlat * tlat * tlat
+ paramArr[7] * tlat * tlat * tlat * tlat * tlat
+ paramArr[8] * tlat * tlat * tlat * tlat * tlat * tlat;
lng *= mp.getLng() < 0 ? -1 : 1;
lat *= mp.getLat() < 0 ? -1 : 1;

return new Point(lng, lat);
}

/**
* 百度经纬度坐标转高德经纬度坐标。网上很常见的一种方式。
*/
private static Point BD09ToGCJ02(Point bdp) {
double x = bdp.getLng() - 0.0065, y = bdp.getLat() - 0.006;
double z = sqrt(x * x + y * y) - 0.00002 * sin(y * xPi);
double theta = atan2(y, x) - 0.000003 * cos(x * xPi);
return new Point(z * cos(theta), z * sin(theta));
}

Python版本:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
xPi = 3.14159265358979324 * 3000.0 / 180.0

# 以下参数来自百度地图的SDK
MCBAND = [12890594.86, 8362377.87, 5591021, 3481989.83, 1678043.12, 0]

MC2LL = [
[1.410526172116255e-8, 0.00000898305509648872, -1.9939833816331, 200.9824383106796, -187.2403703815547, 91.6087516669843, -23.38765649603339, 2.57121317296198, -0.03801003308653, 17337981.2],
[-7.435856389565537e-9, 0.000008983055097726239, -0.78625201886289, 96.32687599759846, -1.85204757529826, -59.36935905485877, 47.40033549296737, -16.50741931063887, 2.28786674699375, 10260144.86],
[-3.030883460898826e-8, 0.00000898305509983578, 0.30071316287616, 59.74293618442277, 7.357984074871, -25.38371002664745, 13.45380521110908, -3.29883767235584, 0.32710905363475, 6856817.37],
[-1.981981304930552e-8, 0.000008983055099779535, 0.03278182852591, 40.31678527705744, 0.65659298677277, -4.44255534477492, 0.85341911805263, 0.12923347998204, -0.04625736007561, 4482777.06],
[3.09191371068437e-9, 0.000008983055096812155, 0.00006995724062, 23.10934304144901, -0.00023663490511, -0.6321817810242, -0.00663494467273, 0.03430082397953, -0.00466043876332, 2555164.4],
[2.890871144776878e-9, 0.000008983055095805407, -3.068298e-8, 7.47137025468032, -0.00000353937994, -0.02145144861037, -0.00001234426596, 0.00010322952773, -0.00000323890364, 826088.5]
]

# 百度经纬度坐标转高德经纬度坐标。网上很常见的一种方式。
def bd09togcj02(bd_lon, bd_lat):
"""
百度坐标系(BD-09)转火星坐标系(GCJ-02)
百度——>谷歌、高德
:param bd_lat:百度坐标纬度
:param bd_lon:百度坐标经度
:return:转换后的坐标列表形式
"""
x, y = bd_lon - 0.0065, bd_lat - 0.006
z = math.sqrt(x * x + y * y) - 0.00002 * math.sin(y * xPi)
theta = math.atan2(y, x) - 0.000003 * math.cos(x * xPi)
return z * math.cos(theta), z * math.sin(theta)

# 参考:http://www.site-digger.com/tools/mct2latlng.html
# 对上述链接中的操作,找到百度地图的SDK的源代码,然后转换成Java
def convertMC2LL(ox, oy) :
x, y = abs(ox), abs(oy)
for i in range(0, len(MCBAND)) :
if y >= MCBAND[i] :
paramArr = MC2LL[i]
break

if paramArr == None:
raise Exception('转换坐标失败')

lng = paramArr[0] + paramArr[1] * abs(ox)
tlat = abs(oy) / paramArr[9]
lat = paramArr[2] + paramArr[3] * tlat + paramArr[4] * tlat * tlat + paramArr[5] * tlat * tlat * tlat + paramArr[6] * tlat * tlat * tlat * tlat + paramArr[7] * tlat * tlat * tlat * tlat * tlat + paramArr[8] * tlat * tlat * tlat * tlat * tlat * tlat
lng *= (-1 if ox < 0 else 1)
lat *= (-1 if oy < 0 else 1)

return lng, lat

效果展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 测试代码
String kejiyuan = "12682891.3894,2559542.0538,12682896.5467,2559793.62794,12682844.7433,2560206.11494,12682734.4366,2561087.88431,12682681.159,2562074.67985,12682831.1964,2562218.1177,12683623.9092,2562188.56753,12683732.2141,2562243.30281,12683877.4858,2562398.46172,12683916.3509,2562839.59511,12683912.6528,2563051.56215,12683997.6458,2563129.82525,12684148.9608,2563136.64739,12684423.9572,2563249.60486,12684687.41,2563412.50587,12685216.0839,2563387.9877,12686427.857,2563282.11338,12687017.2378,2563194.53233,12687547.0621,2563146.47597,12687711.6126,2563191.93924,12688072.3621,2563098.80984,12688551.9725,2562924.51818,12688595.2954,2562866.34103,12688581.4473,2562769.94654,12688592.8397,2562733.23579,12689180.0331,2562464.27042,12689211.9588,2562420.55064,12689195.8798,2562314.3281,12689168.3537,2562313.76442,12688839.1722,2562275.22138,12688521.5892,2562344.85031,12688215.549,2562370.2208,12687875.1633,2562296.16518,12687304.0261,2562224.77222,12686776.2826,2562209.27294,12686464.1449,2562201.14685,12686253.3935,2562183.17902,12686147.8737,2562159.13696,12686097.5228,2562114.03074,12686174.2562,2560903.22104,12686144.5094,2560727.85702,12686162.7094,2560701.00483,12686702.0782,2560744.58401,12686715.7214,2560727.54607,12686694.5355,2560518.79091,12686469.0556,2560122.34138,12686281.1211,2559770.33875,12686231.908,2559542.23454,12686214.5986,2559162.03758,12686197.169,2559118.19258,12685318.8834,2559094.19446,12685143.0646,2559094.6559,12684986.7893,2559013.13744,12684889.126,2558895.30627,12684835.3891,2558858.28003,12684600.8812,2558847.14735,12684139.8292,2558854.49449,12683748.6599,2558894.79101,12683582.3085,2558936.30384,12683435.5368,2559030.43944,12683260.9697,2559068.85924,12682852.8989,2559166.22101,12682834.3698,2559181.22174,12682891.3894,2559542.0538";
String pss = kejiyuan;
String[] points = pss.split(",");
if (points.length % 2 == 0) {
for (int i = 0; i < points.length; i += 2) {
System.out.println(
"[" +
BD09ToGCJ02(convertMC2LL(
new Point(
Double.parseDouble(points[i]),
Double.parseDouble(points[i + 1])
)
))
+ "],"
);
}
}

打开高德地图多边形绘制DEMO示例,然后将上述代码的对应执行结果,以正确的形式粘贴到DEMO代码的相应地方:

然后点击运行,查看结果:
百度墨卡托坐标转成百度经纬度坐标后,在高德地图上展示情况
百度墨卡托坐标转成百度经纬度坐标后在高德地图上展示情况
将百度坐标转换成高德地图坐标后在高德地图中的展示情况
将百度坐标转换成高德地图坐标后在高德地图中的展示情况
百度地图原始数据
百度地图原始数据

后面经过尝试、发现差距并不是特别大,已使用这种方法进行坐标转换

项目架构解释说明

模块概览

base-service

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
33
34
35
36
37
38
39
40
41
base-service
├── pom.xml
└── src
└── main
└── java
└── com
└── haylion
└── realTimeBus
├── advice
│   └── SystemAdvice.java # 分页注解实现
├── annotation
│   └── PageAble.java # 分页注解
├── bean
│   ├── BaseModel.java # 含有id熟悉的基础bean
│   ├── Condition.java # 条件查询时的条件
│   ├── ResultPageView.java # 被分页注解标记后,改函数返回的对象,将被包装成该对象(一个普通的bean类)
│   ├── SMSConf.java # 将yaml文件中sms相关配置信息写入此类对象中
│   └── Sort.java # 查询时排序方式
├── handler
│   └── MySqlJsonHandler.java # MyBatis如何存储JSONObject、如何读取JSONObject
├── http
│   └── HttpInvoker.java # RestTemplate单例容器
├── interceptor
│   └── sql
│   ├── MyPageHelper.java # 覆盖afterAll后的PageHelper,在MyPageInterceptor中进行调用
│   ├── MyPageInterceptor.java # PageHelper中拦截器PageInterceptor的源码,有自定义修改
│   └── SqlLogHandler.java # 把将要执行的SQL打印出来,集成在MyPageInterceptor中
├── mappers
│   └── BaseMapper.java # 定义了访问数据库的一些基本接口
├── msg
│   └── RetStubMsg.java # 自定义ApplicationException接收的参数
└── service
├── BaseCacheService.java # 定义了一些基本的Redis访问接口
├── BaseService.java # 实现了service基础数据库操作的抽象类
├── CacheService.java # BaseCacheService的具体封装实现
├── DistributedLock.java # 通过redis实现的一种分布式锁
├── MQService.java # 阿里MQ操作封装
├── SMSService.java # 阿里短信操作封装
├── SysThreadPool.java # 自定义线程池
├── UploadService.java # 文件上传,有文件头部信息校验
└── WXPayService.java # 微信支付相关操作封装

common-service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
base-support/common-service
├── pom.xml
└── src
└── main
└── java
└── com
└── haylion
└── realTimeBus
└── common
├── code
│   ├── RetStub.java # ApplicationException所依赖的对象,定义了业务逻辑错误的访问方法
│   └── SysStubInfo.java # 系统默认的错误码信息
├── exception
   └── ApplicationException.java # 自定义异常,用来处理业务逻辑中出现的异常,其中依赖RetStub对象,来描述错误码、错误信息

facade-service

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
base-support/facade-service
├── pom.xml
└── src
└── main
└── java
└── com
└── haylion
└── realTimeBus
└── facade
├── BaseInitializer.java # 自定义Server初始化类,其中加入了拦截器ActionInterceptor,并指定拦截所有的请求
├── advice
│   └── ResponseAdvice.java # 将所有返回的body中的数据,转换成JsonView的形式
├── annotation
│   └── AnonymousSupport.java # 跳过ActionInterceptor中的校验、塞入userId
├── exception
│   └── ExceptionHandle.java # 在请求的处理过程中,出现任何异常,都会调用该类中的处理方法,将错误信息,以JsonView的形式返回。
├── filter
│   ├── RequestWrapper.java # 将请求相关的数据,如headers、queryparams、requestbody、method、url保存到本线程的LogTrace中
│   └── TraceCopyFilter.java # 拦截器,拦截所有的请求,并将请求转换成RequestWrapper
├── intercepter
│   └── ActionInterceptor.java # 所有的请求都会经过该类处理,可以在此类中对类进行自定义操作,如按需鉴定权限、添加userId到header中
├── log
│   ├── HttpTraceLog.java # HTTP请求log类
│   └── LogTrace.java # 利用ThreadLocal获取、设置HttpTraceLog
├── validator
│   └── ValidatorConfiguration.java #
└── view
└── JsonView.java # API统一响应模板

主要功能

拦截鉴权— ActionInterceptor

本项目中鉴定登录状态的方式为:在请求头部加入token,然后在拦截器ActionInterceptor.java中,从请求头部中取出token,并依据token来获取userId,然后将userId插入到头部中;如果上述过程,出现tokennulluserIdnull,那么该请求将被视为非登录状态,将不会传递到Controller层。

如何跳过校验?

在Controller的方法上,加上@AnonymousSupport注解,在ActionInterceptor.java中,会通过Method方法,获取@AnonymousSupport,如果存在就不进行后面的登录状态校验。

1
2
3
4
5
HandlerMethod handlerMethod = (HandlerMethod) o;
AnonymousSupport annotation = handlerMethod.getMethod().getAnnotation(AnonymousSupport.class);
if (annotation != null) {
return true;
}

如何将userId添加到请求的headers中?

通过token在Redis中获取到userId后,如何在headers中添加userId键值对,略微繁杂,但是目的很单纯。设置值的代码如下所示:

1
2
3
4
5
6
7
MimeHeaders mimeHeaders = (MimeHeaders) headers.get(coyoteRequest);
//in case of do not cover the specify key
String header = mimeHeaders.getHeader(key);
logger.info("Original value of <" + key + "> is " + header);
mimeHeaders.removeHeader(key);
// key = "userId", value即token值
mimeHeaders.addValue(key).setString(value);

在前面,有一个对request类型进行判断的语句,主要目的是为了避免NullPointerException,因为后面通过反射获取属性的时候,可能会由于request类型不同,而获取不到对应的Field,从而导致出现异常。

1
2
3
4
5
6
7
8
9
10
11
if (request instanceof StandardMultipartHttpServletRequest) {
// 文件上传时的类型是这个
StandardMultipartHttpServletRequest standardMultipartHttpServletRequest = (StandardMultipartHttpServletRequest) request;
RequestWrapper requestWrapper = (RequestWrapper) standardMultipartHttpServletRequest
.getRequest();
request = (HttpServletRequest) requestWrapper.getRequest();
} else if (request instanceof RequestWrapper) {
// 通常情况下是这个,因为我们在Filter中对request进行过包装,详情见下面请求日志部分
RequestWrapper requestWrapper = (RequestWrapper) request;
request = (HttpServletRequest) requestWrapper.getRequest();
}

请求日志

主要的任务是将请求所有的参数(如:url中的参数、方法、headers、requestBody等)都以直观的方式打印出来。它的主要流程有两处:

  1. 拦截器TraceCopyFilter初始化RequestWrapper时,将请求所有的信息都保存到HttpTraceLog中,并通过LogTrace保存在当前线程中(利用ThreadLocal)。

    1
    2
    3
    4
    5
    6
    7
    LogTrace.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());
  2. ResponseAdvice包装完返回信息之后,会在finally中将所有的请求信息,通过log的形式打印出来。打印完成后,会通过LogTrace将请求的信息从本线程中移除掉(利用ThreadLocal)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    try {
    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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// request == null 是一个标志位
this.request = null;
// IO读取
try (InputStream inputStream = request.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))){
char[] charBuffer = new char[128];
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
sb.append(charBuffer, 0, bytesRead);
}
} catch (IOException ex) {
ex.printStackTrace();
}
// 保存到内存中
requestBody = sb.toString();

但是收到POST请求时(参考链接),会产生两个TCP数据包(并不是所有浏览器都会在POST中发送两次包,Firefox就只发送一次),即浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。所以在拿POST请求中body时,如果直接读出来,可能导致后面框架再去读的时候,出现读不了的情况,所以在RequestWrapper中,重写了getInputStream方法,在request不为空,即没有读取过body(这种情况就是在上传文件的情况),直接以requestBody作为输入流,提供给框架读取,上述问题便解决了。代码如下:

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
@Override
public ServletInputStream getInputStream() throws IOException {
if (this.request != null) {
// 不为空,说明没有读取过body,即为文件上传请求,此时直接返回request.getInputStream()
return request.getInputStream();
}
// 以requestBody作为输入流
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(requestBody.getBytes());
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}

@Override
public boolean isReady() {
return false;
}

@Override
public void setReadListener(ReadListener readListener) {
}

@Override
public int read() throws IOException {
// 以requestBody作为输入流
return byteArrayInputStream.read();
}
};
}

SQL日志

SqlLogHandler在SQL拦截中进行调用,它主要做的工作是把SQL中的?替换成实际的值,并打印出执行时间。

API统一的返回数据

ResponseAdvice中,会将数据都转化成JsonView的形式。

其中有一个问题,就是当返回的类型是String的时候,不能包装String类型,只能以String的形式返回。这是由于整个SpringMVC框架的设计问题。假设有如下业务代码:

1
2
3
4
5
@GetMapping("test")
@AnonymousSupport
public String test() {
return "test";
}

这时候的返回值如下:

因为是以String的类型直接返回了,上述的返回格式也是理所当然。但是如果将String包装成JsonView,然后返回会怎么样?修改ResponseAdvice如下:

1
2
3
4
5
6
7
8
9
if (o instanceof JsonView ) {
result = o;
return result;
}
if (o == null) {
o = EMPTY_DATA;
}
result = new JsonView<>(SysStubInfo.DEFAULT_SUCCESS, o);
return result;

这时候如果再次访问,程序会报错如下:

报错的堆栈信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java.lang.ClassCastException: com.haylion.realTimeBus.facade.view.JsonView cannot be cast to java.base/java.lang.String
at org.springframework.http.converter.StringHttpMessageConverter.getContentLength(StringHttpMessageConverter.java:43)
at org.springframework.http.converter.AbstractHttpMessageConverter.addDefaultHeaders(AbstractHttpMessageConverter.java:259)
at org.springframework.http.converter.AbstractHttpMessageConverter.write(AbstractHttpMessageConverter.java:210)
at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:275)
at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:180)
at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:82)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:119)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:877)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:783)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:991)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:925)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:974)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:866)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:635)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:851)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:742)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)
at com.haylion.realTimeBus.facade.filter.TraceCopyFilter.doFilter(TraceCopyFilter.java:52)

上面的错误简单理解,类型转换错误,也就是需要一个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
2
3
protected Long getContentLength(T t, @Nullable MediaType contentType) throws IOException {
return null;
}

这时候应该直接看StringHttpMessageConverter中的getContentLength()方法如下:

1
2
3
4
5
@Override
protected Long getContentLength(String str, @Nullable MediaType contentType) {
Charset charset = getContentTypeCharset(contentType);
return (long) str.getBytes(charset).length;
}

然后再将转换后的JsonView作为抽象函数getContentLength()(这时就是StringHttpMessageConverter的该函数)的第一个参数,如下:

1
2
3
4
protected Long getContentLength(String str, @Nullable MediaType contentType) {
Charset charset = getContentTypeCharset(contentType);
return (long) str.getBytes(charset).length;
}

第一个参数为String,但是实际上是JsonView。因此,ClassCastException在所难免。在ResponseAdvice中,将String直接返回,可以避免出现这种不太好修复的错误。

替代办法:

但是如果非要返回String类型,并且需要包装成JsonView形式,可以考虑直接在Controller中将String包装成JsonView,然后返回,如下:

1
2
3
4
5
@GetMapping("test")
@AnonymousSupport
public Object test() {
return new JsonView<>(SysStubInfo.DEFAULT_SUCCESS, "test");
}

结果:

异常处理

参看ExceptionHandle具体实现及写法、以及相关源码注释。

分页–PageHelper

用法

直接在方法上加上@PageAble注解,并在该方法中传入两个参数,分别为pagesize,在该方法返回后,会得到一个ResultPageView封装对象,其中包含分页相关信息。

工作流程

SystemAdvice定义一个切面,切点是@annotation(com.haylion.realTimeBus.annotation.PageAble)。也就是说,每个被@PageAble注解过的方法,都将执行下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final String PAGE_ABLE = "@annotation(com.haylion.realTimeBus.annotation.PageAble)";

@Around(PAGE_ABLE)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
logger.info("execute method : " + proceedingJoinPoint.getSignature().getName());
try {
// 进入被@PageAble注解的方法前的准备工作
prepare(proceedingJoinPoint);
// 执行被@PageAble注解的方法
Object obj = proceedingJoinPoint.proceed();
// 执行被@PageAble注解的方法后,执行扫尾工作
Object result = after(obj);
return result;
} catch (Throwable throwable) {
logger.error("aspect execute error : ", throwable);
throw throwable;
} finally {
PageHelper.clearPage();
}
}

准备工作:主要是获取pagesize的值,然后调用PageHelperstartPage方法,初始化分页信息。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// PageAble中page和size的默认值分别是1和20
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageAble {
String pageSizeName() default "size";
String pageNumName() default "page";
int pageSize() default 20;
int pageNum() default 1;
}
// 准备分页
private void prepare(ProceedingJoinPoint point) throws Exception {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
PageAble pageAble = targetMethod.getAnnotation(PageAble.class);
String numName = pageAble.pageNumName();
String sizeName = pageAble.pageSizeName();
// 先获取默认的page和size值
int pageNo = pageAble.pageNum();
int pageSize = pageAble.pageSize();
Object[] paramValues = point.getArgs();
String[] paramNames = methodSignature.getParameterNames();
int length = paramNames.length;
// 遍历该方法中的所有参数,如果有page和size信息,那么就覆盖默认值为用户传入的值
for (int i = 0; i < length; i++) {
if (paramNames[i].equals(numName)) {
pageNo = (Integer) paramValues[i];
} else if (paramNames[i].equals(sizeName)) {
pageSize = (Integer) paramValues[i];
}
}
// 该方法利用ThreadLocal在本线程中插入一个分页信息的对象Page
PageHelper.startPage(pageNo, pageSize);
}
// startPage()方法的最终实现
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}

protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

进入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
    @Override
    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
    13
    2019-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);

    @Override
    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
    //
    @Override
    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
    @Override
    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()方法
    @Override
    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()方法
    @Override
    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
    3
    public class Page<E> extends ArrayList<E> implements Closeable {
    // ...
    }

    在return后,还会执行finally中的处理代码,即com.haylion.realTimeBus.interceptor.sql.MyPageHelperafterAll()方法。其中实现如下:

    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()
    // 这个方法是我们自定义的方法,用来处理执行完前面所述的切点后,保留分页信息,进行再次封装
    @Override
    public void afterAll() {
    Page<Object> localPage = getLocalPage();
    // 删除分页信息
    super.afterAll();
    // 设置回本线程中
    setLocalPage(localPage);
    }

    // super.afterAll()。这个方法可以简单理解成,清楚掉本线程中的分页信息
    @Override
    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
2
3
4
5
6
7
8
9
10
11
12
13
private Object after(Object obj) {
// ...
PageInfo<?> pageInfo;
Page<Object> localPage = PageHelper.getLocalPage();
long total = localPage.getTotal();
int pageNum = localPage.getPageNum();
int pages = localPage.getPages();
List<?> list = (List<?>) obj;
// ...
pageInfo = new PageInfo((list));
ResultPageView<?> resultPageView = new ResultPageView<>(total, pageNum, pages, pageInfo.getList());
return resultPageView;
}

至此,还有最重要的一个步骤,是在切面处理完成后,将分页信息从本线程中删除,没有此操作,后续操作会出现莫名其妙的错误。也就是finally语句中的PageHelper.clearPage();

1
2
3
4
5
6
7
8
9
10
11
try {
prepare(proceedingJoinPoint);
Object obj = proceedingJoinPoint.proceed();
Object result = after(obj);
return result;
} catch (Throwable throwable) {
logger.error("aspect execute error : ", throwable);
throw throwable;
} finally {
PageHelper.clearPage();
}

局限

这就限定了在一个被PageAble注解了的方法上,只能执行一条查询。如果对于一个到来的请求,需要进行两次或以上的查询,并且某一条查询需要分页的情况,如果所有的查询都放在被PageAble注解的方法下,执行会出现问题(出现不必要的分页操作)。但是可以通过组装的形式,完成该项需求。

MyBatis中的#与$符号的区别

Mybatis中有很多可以学习的地方,#和$是两种常见的值替换方式,今天站在源码的角度去分析其解析过程。

关键源码

因为这段代码功能单一,对后续的流程影响不大,搞清楚这段代码的作用,基本上#{}${}的区别,在源代码上已经是清楚了。了解了这段代码之后,再进行后续的解析流程,就不会陷入这段代码的逻辑中,将整个视野投入到流程当中。

位置:GenericTokenParser.java –> parse()

1
2
3
4
5
6
7
8
9
10
private final String openToken;
private final String closeToken;
// 当匹配到#{}或${}后,对其中的文本进行的操作。
private final TokenHandler handler;

public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) {
this.openToken = openToken;
this.closeToken = closeToken;
this.handler = handler;
}

首先它有3个成员变量,可以理解成:当匹配到已openToken开头,closeToken结尾的字符串后,对其中的字符串执行handler.handleToken()方法。

这个handler就是一个典型的策略模式。它是一个只含有``函数的接口:

1
2
3
public interface TokenHandler {
String handleToken(String content);
}

也就是说#{}${}的处理逻辑都是封装到handleToken中。

寻找是否含有#{}${}的逻辑如下:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class GenericTokenParser {
public String parse(String text) {
if (text == null || text.isEmpty()) {
return "";
}
// search open token
int start = text.indexOf(openToken, 0);
if (start == -1) {
return text;
}
char[] src = text.toCharArray();
int offset = 0;
final StringBuilder builder = new StringBuilder();
StringBuilder expression = null;
while (start > -1) {
if (start > 0 && src[start - 1] == '\\') {
// this open token is escaped. remove the backslash and continue.
builder.append(src, offset, start - offset - 1).append(openToken);
offset = start + openToken.length();
} else {
// found open token. let's search close token.
if (expression == null) {
expression = new StringBuilder();
} else {
expression.setLength(0);
}
builder.append(src, offset, start - offset);
offset = start + openToken.length();
int end = text.indexOf(closeToken, offset);
while (end > -1) {
if (end > offset && src[end - 1] == '\\') {
// this close token is escaped. remove the backslash and continue.
expression.append(src, offset, end - offset - 1).append(closeToken);
offset = end + closeToken.length();
end = text.indexOf(closeToken, offset);
} else {
expression.append(src, offset, end - offset);
offset = end + closeToken.length();
break;
}
}
if (end == -1) {
// close token was not found.
builder.append(src, start, src.length - start);
offset = src.length;
} else {
builder.append(handler.handleToken(expression.toString()));
offset = end + closeToken.length();
}
}
start = text.indexOf(openToken, offset);
}
if (offset < src.length) {
builder.append(src, offset, src.length - offset);
}
return builder.toString();
}
}

用到GenericTokenParser的地方如下图所示:

用到的地方

仔细看一看上面的图片,会有一个疑问,那就是为什么#{}${}分别有2种不同的解析方法?这个可以在后面的分析中得出答案。这里直接给出我们平常所认为的那种处理方式的源代码,即#变成?,然后$直接变成相应的值,如下:

  • #{}的处理方式

    位置:SqlSourceBuidler.java –> ParameterMappingTokenHandler

    1
    2
    3
    4
    5
    @Override
    public String handleToken(String content) {
    parameterMappings.add(buildParameterMapping(content));
    return "?";
    }
  • ${}的处理方式

    位置:PropertyParser.java –> VariableTokenHandler

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @Override
    public String handleToken(String content) {
    if (variables != null) {
    String key = content;
    if (enableDefaultValue) {
    final int separatorIndex = content.indexOf(defaultValueSeparator);
    String defaultValue = null;
    if (separatorIndex >= 0) {
    key = content.substring(0, separatorIndex);
    defaultValue = content.substring(separatorIndex + defaultValueSeparator.length());
    }
    if (defaultValue != null) {
    return variables.getProperty(key, defaultValue);
    }
    }
    if (variables.containsKey(key)) {
    return variables.getProperty(key);
    }
    }
    return "${" + content + "}";
    }

从上面的代码来看,基本上的问题已经解决了。下面是MyBatis对XML中SQL语句的解析流程的分析。

示例代码

1
2
3
4
5
6
7
8
public static void main(String[] args) throws IOException {
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream("mybatis-config.xml"));
SqlSession session = sqlSessionFactory.openSession();

BizDriverMapper mapper = session.getMapper(BizDriverMapper.class);
List<BizDriver> list = mapper.getByNameOrNumber("hello");
System.out.println(list.size());
}

构建SqlSessionFactory阶段

SqlSessionFactoryBuilder.java –> build(InputStream, String, Properties)

1
2
3
4
5
6
7
public SqlSessionFactory build(InputStream inputStream, String environment, Properties properties) {
try {
XMLConfigBuilder parser = new XMLConfigBuilder(inputStream, environment, properties);
return build(parser.parse());
}
...
}

XMLConfigBuilder.java –> parse()

1
2
3
4
5
6
7
8
public Configuration parse() {
if (parsed) {
throw new BuilderException("Each XMLConfigBuilder can only be used once.");
}
parsed = true;
parseConfiguration(parser.evalNode("/configuration"));
return configuration;
}

XMLConfigBuilder.java –> parseConfiguration()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void parseConfiguration(XNode root) {
try {
//issue #117 read properties first
propertiesElement(root.evalNode("properties"));
Properties settings = settingsAsProperties(root.evalNode("settings"));
loadCustomVfs(settings);
typeAliasesElement(root.evalNode("typeAliases"));
pluginElement(root.evalNode("plugins"));
objectFactoryElement(root.evalNode("objectFactory"));
objectWrapperFactoryElement(root.evalNode("objectWrapperFactory"));
reflectorFactoryElement(root.evalNode("reflectorFactory"));
settingsElement(settings);
// read it after objectFactory and objectWrapperFactory issue #631
environmentsElement(root.evalNode("environments"));
databaseIdProviderElement(root.evalNode("databaseIdProvider"));
typeHandlerElement(root.evalNode("typeHandlers"));
mapperElement(root.evalNode("mappers"));
} catch (Exception e) {
throw new BuilderException("Error parsing SQL Mapper Configuration. Cause: " + e, e);
}
}

XMLConfigBuilder.java –> mapperElement()

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
private void mapperElement(XNode parent) throws Exception {
if (parent != null) {
for (XNode child : parent.getChildren()) {
if ("package".equals(child.getName())) {
String mapperPackage = child.getStringAttribute("name");
configuration.addMappers(mapperPackage);
} else {
String resource = child.getStringAttribute("resource");
String url = child.getStringAttribute("url");
String mapperClass = child.getStringAttribute("class");
if (resource != null && url == null && mapperClass == null) {
ErrorContext.instance().resource(resource);
InputStream inputStream = Resources.getResourceAsStream(resource);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, resource, configuration.getSqlFragments());
mapperParser.parse();
} else if (resource == null && url != null && mapperClass == null) {
ErrorContext.instance().resource(url);
InputStream inputStream = Resources.getUrlAsStream(url);
XMLMapperBuilder mapperParser = new XMLMapperBuilder(inputStream, configuration, url, configuration.getSqlFragments());
mapperParser.parse();
} else if (resource == null && url == null && mapperClass != null) {
Class<?> mapperInterface = Resources.classForName(mapperClass);
configuration.addMapper(mapperInterface);
} else {
throw new BuilderException("A mapper element may only specify a url, resource or class, but not more than one.");
}
}
}
}
}

XMLMapperBuilder.java –> parse()

1
2
3
4
5
6
7
8
9
10
11
public void parse() {
if (!configuration.isResourceLoaded(resource)) {
configurationElement(parser.evalNode("/mapper"));
configuration.addLoadedResource(resource);
bindMapperForNamespace();
}

parsePendingResultMaps();
parsePendingCacheRefs();
parsePendingStatements();
}

XMLMapperBuilder.java –> configurationElement()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void configurationElement(XNode context) {
try {
String namespace = context.getStringAttribute("namespace");
if (namespace == null || namespace.equals("")) {
throw new BuilderException("Mapper's namespace cannot be empty");
}
builderAssistant.setCurrentNamespace(namespace);
cacheRefElement(context.evalNode("cache-ref"));
cacheElement(context.evalNode("cache"));
parameterMapElement(context.evalNodes("/mapper/parameterMap"));
resultMapElements(context.evalNodes("/mapper/resultMap"));
sqlElement(context.evalNodes("/mapper/sql"));
// 处理sql的入口
buildStatementFromContext(context.evalNodes("select|insert|update|delete"));
} catch (Exception e) {
throw new BuilderException("Error parsing Mapper XML. The XML location is '" + resource + "'. Cause: " + e, e);
}
}

处理sql的入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void buildStatementFromContext(List<XNode> list) {
if (configuration.getDatabaseId() != null) {
buildStatementFromContext(list, configuration.getDatabaseId());
}
buildStatementFromContext(list, null);
}

private void buildStatementFromContext(List<XNode> list, String requiredDatabaseId) {
for (XNode context : list) {
final XMLStatementBuilder statementParser = new XMLStatementBuilder(configuration, builderAssistant, context, requiredDatabaseId);
try {
statementParser.parseStatementNode();
} catch (IncompleteElementException e) {
configuration.addIncompleteStatement(statementParser);
}
}
}

XMLStatementBuilder.java –> parseStatementNode()

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public void parseStatementNode() {
String id = context.getStringAttribute("id");
String databaseId = context.getStringAttribute("databaseId");

if (!databaseIdMatchesCurrent(id, databaseId, this.requiredDatabaseId)) {
return;
}

Integer fetchSize = context.getIntAttribute("fetchSize");
Integer timeout = context.getIntAttribute("timeout");
String parameterMap = context.getStringAttribute("parameterMap");
String parameterType = context.getStringAttribute("parameterType");
Class<?> parameterTypeClass = resolveClass(parameterType);
String resultMap = context.getStringAttribute("resultMap");
String resultType = context.getStringAttribute("resultType");
String lang = context.getStringAttribute("lang");
LanguageDriver langDriver = getLanguageDriver(lang);

Class<?> resultTypeClass = resolveClass(resultType);
String resultSetType = context.getStringAttribute("resultSetType");
StatementType statementType = StatementType.valueOf(context.getStringAttribute("statementType", StatementType.PREPARED.toString()));
ResultSetType resultSetTypeEnum = resolveResultSetType(resultSetType);

String nodeName = context.getNode().getNodeName();
SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
boolean useCache = context.getBooleanAttribute("useCache", isSelect);
boolean resultOrdered = context.getBooleanAttribute("resultOrdered", false);

// Include Fragments before parsing
XMLIncludeTransformer includeParser = new XMLIncludeTransformer(configuration, builderAssistant);
includeParser.applyIncludes(context.getNode());

// Parse selectKey after includes and remove them.
processSelectKeyNodes(id, parameterTypeClass, langDriver);

// Parse the SQL (pre: <selectKey> and <include> were parsed and removed)
SqlSource sqlSource = langDriver.createSqlSource(configuration, context, parameterTypeClass);
String resultSets = context.getStringAttribute("resultSets");
String keyProperty = context.getStringAttribute("keyProperty");
String keyColumn = context.getStringAttribute("keyColumn");
KeyGenerator keyGenerator;
String keyStatementId = id + SelectKeyGenerator.SELECT_KEY_SUFFIX;
keyStatementId = builderAssistant.applyCurrentNamespace(keyStatementId, true);
if (configuration.hasKeyGenerator(keyStatementId)) {
keyGenerator = configuration.getKeyGenerator(keyStatementId);
} else {
keyGenerator = context.getBooleanAttribute("useGeneratedKeys",
configuration.isUseGeneratedKeys() && SqlCommandType.INSERT.equals(sqlCommandType))
? Jdbc3KeyGenerator.INSTANCE : NoKeyGenerator.INSTANCE;
}

builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
resultSetTypeEnum, flushCache, useCache, resultOrdered,
keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
}

其中createSqlSource()的对象langDriver来自XMLLanguageDriver

XMLLanguageDriver.java –> createSqlSource()

1
2
3
4
5
@Override
public SqlSource createSqlSource(Configuration configuration, XNode script, Class<?> parameterType) {
XMLScriptBuilder builder = new XMLScriptBuilder(configuration, script, parameterType);
return builder.parseScriptNode();
}

关键步骤:XMLScriptBuilder.java –> parseScriptNode()

这里面的每一行代码都可以仔细去看、并理解它的意思

1
2
3
4
5
6
7
8
9
10
11
12
13
public SqlSource parseScriptNode() {
// 判定是否含有${},如果有,isDynamic-->true
MixedSqlNode rootSqlNode = parseDynamicTags(context);
SqlSource sqlSource = null;
if (isDynamic) {
// 有${}不做任何处理
sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
} else {
// 没有${},把#{}都替换成?
sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
}
return sqlSource;
}

通过上面的分析可知,会有两种情况,即是否为动态。一条语句开始的时候都被包装成MixedSqlNode,然后再通过判断是否为动态,将其封装成DynamicSqlSourceRawSqlSource。这两种的含义是:DynamicSqlSource${}不做任何处理,RawSqlSource没有${},把#{}都替换成?。是如何分配的呢?是通过GenericTokenParser来寻找$符号,然后在handler中,将isDynamic标记为true,代码如下:

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
protected MixedSqlNode parseDynamicTags(XNode node) {
List<SqlNode> contents = new ArrayList<SqlNode>();
NodeList children = node.getNode().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
XNode child = node.newXNode(children.item(i));
if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE || child.getNode().getNodeType() == Node.TEXT_NODE) {
String data = child.getStringBody("");
TextSqlNode textSqlNode = new TextSqlNode(data);
// isDynamic()中封装了对GenericTokenParser的调用
if (textSqlNode.isDynamic()) {
contents.add(textSqlNode);
isDynamic = true;
} else {
contents.add(new StaticTextSqlNode(data));
}
} else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) { // issue #628
String nodeName = child.getNode().getNodeName();
XMLScriptBuilder.NodeHandler handler = nodeHandlerMap.get(nodeName);
if (handler == null) {
throw new BuilderException("Unknown element <" + nodeName + "> in SQL statement.");
}
handler.handleNode(child, contents);
isDynamic = true;
}
}
return new MixedSqlNode(contents);
}

isDynamic()中封装了对GenericTokenParser的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public boolean isDynamic() {
DynamicCheckerTokenParser checker = new DynamicCheckerTokenParser();
GenericTokenParser parser = createParser(checker);
parser.parse(text);
return checker.isDynamic();
}
private static class DynamicCheckerTokenParser implements TokenHandler {

private boolean isDynamic;

public DynamicCheckerTokenParser() {
// Prevent Synthetic Access
}

public boolean isDynamic() {
return isDynamic;
}
// 将本对象的变量置为true
@Override
public String handleToken(String content) {
this.isDynamic = true;
return null;
}
}

在处理RawSqlSource时的初始化过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public RawSqlSource(Configuration configuration, SqlNode rootSqlNode, Class<?> parameterType) {
this(configuration, getSql(configuration, rootSqlNode), parameterType);
}

public RawSqlSource(Configuration configuration, String sql, Class<?> parameterType) {
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> clazz = parameterType == null ? Object.class : parameterType;
// 所有的#{}都将被替换成?
sqlSource = sqlSourceParser.parse(sql, clazz, new HashMap<String, Object>());
}

// SqlSourceBuilder.java
public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
String sql = parser.parse(originalSql);
return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}

其中ParameterMappingTokenHandlerhandleToken()方法,也就是匹配成功后的回调,实现如下:

1
2
3
4
5
@Override
public String handleToken(String content) {
parameterMappings.add(buildParameterMapping(content));
return "?";
}

在处理RawSqlSource时,没有这方面的处理,因此在这里就不贴其代码流程。反思:因为这是初始化阶段,可以理解成开机启动脚本,所以#{}的值是确定的,也就是说要变成,而${}的值在这个阶段就是未知的,因为还没有具体的SQL语句要生成,直接替换的数据还不确定,所以含有${}的语句,在初始化阶段是先不处理。

参考:

Flask如何使用logging.FileHandler将日志保存到文件

需求

将日志尽可能往文件中输,自带的默认只输出到屏幕上。

代码

获取文件名

1
2
3
4
5
6
7
8
9
10
11
def get_custom_file_name():
def make_dir(make_dir_path):
path = make_dir_path.strip()
if not os.path.exists(path):
os.makedirs(path)
return path
log_dir = "ac_logs"
file_name = 'logger-' + time.strftime('%Y-%m-%d', time.localtime(time.time())) + '.log'
file_folder = os.path.abspath(os.path.dirname(__file__)) + os.sep + log_dir
make_dir(file_folder)
return file_folder + os.sep + file_name

配置logging

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
dictConfig({
'version': 1,
'formatters': {'default': {
'format': '%(asctime)s - %(levelname)s - %(filename)s - %(funcName)s - %(lineno)s - %(message)s',
}},
'handlers': {
'default': {
'class': 'logging.StreamHandler',
'stream': 'ext://flask.logging.wsgi_errors_stream',
'formatter': 'default'
},
'custom': {
'class' : 'logging.FileHandler',
'formatter': 'default',
'filename' : get_custom_file_name(),
'encoding' : 'utf-8'
},
},
'root': {
'level': 'INFO',
'handlers': ['custom']
}
})

代码分析

在官方文档中,有一个默认的handler,当我添加一个自定义的handler,名叫custom的时候,读取配置失败,程序中断,也就无法继续执行下去,提示说,少一个叫做filename的参数

loggin.FileHandler的构造函数中,有一个必填的参数,叫做filename。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileHandler(StreamHandler):
"""
A handler class which writes formatted logging records to disk files.
"""
def __init__(self, filename, mode='a', encoding=None, delay=False):
"""
Open the specified file and use it as the stream for logging.
"""
# Issue #27493: add support for Path objects to be passed in
filename = os.fspath(filename)
#keep the absolute path, otherwise derived classes which use this
#may come a cropper when the current directory changes
self.baseFilename = os.path.abspath(filename)
self.mode = mode
self.encoding = encoding
self.delay = delay

如何才能传入参数filename?

①进入dictConfig()

1
2
3
def dictConfig(config):
"""Configure logging using a dictionary."""
dictConfigClass(config).configure()

②进入configure(),找到对handler的处理逻辑,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 前面的代码省略
# 获取handlers
handlers = config.get('handlers', EMPTY_DICT)
deferred = []
# 遍历其中的每一个handler
for name in sorted(handlers):
try:
# 具体处理每一个handler
handler = self.configure_handler(handlers[name])
handler.name = name
handlers[name] = handler
except Exception as e:
if 'target not configured yet' in str(e.__cause__):
deferred.append(name)
else:
raise ValueError('Unable to configure handler '
'%r' % name) from e
# 后面的代码省略

③进入具体处理handler的逻辑,self.configure_handler()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def configure_handler(self, config):
# 省略代码若干行 ...
if '()' in config:
c = config.pop('()')
if not callable(c):
c = self.resolve(c)
factory = c
else:
cname = config.pop('class')
klass = self.resolve(cname)
# 省略代码若干行 ...
# 此处的kclass就是配置的class名所对应的类
factory = klass
props = config.pop('.', None)
# 读取完类名,获取到该类、获取了formatter之后,接着读取conf里面的数据
# 在这里将剩下所定义的参数,弄成dict类型的数据
kwargs = {k: config[k] for k in config if valid_ident(k)}
try:
# 直接将dict类型的数据作为函数入参,实例化出一个FileHandler
result = factory(**kwargs)
except TypeError as te:
if "'stream'" not in str(te):
raise

其中的调试数据截图如下:
在这里插入图片描述
如果没有配置filename的信息,那么实例化类的时候就报错也是理所应当,因此还可以尝试性地配置encoding参数到其中,工作正常。

总结

虽然对这一块的整体了解还不够,但是对于能够参照官方文档,参考源代码实现,完成自己的想法,做到的那一刻还是有点成就感。

【参考】
官方文档:http://flask.pocoo.org/docs/dev/logging