这个问题非常有趣,不是SpringMVC 的问题,是使用了两种请求方式暴露出来的。
功能模块中,提供两个 Http 服务。一个是列表查询(application/json 请求),一个是列表导出(表单请求)。运行发现新增的参数,同样的 Http 请求,一个有值,一个没有🙄
代码如下:
/**
* application/json 请求
* @param param RequestResponseBodyMethodProcessr 处理 HttpServletRequest 参数
*/
@PostMapping(value = "query")
public ResponseResult<Page<SomeData>> queryByCondition(@RequestBody SomeParam param){
}
/**
* application/x-www-form-urlencoded 请求
* @param param ServletModelAttributeMethodProcessor 处理 HttpServletRequest 参数
*/
@PostMapping(value = "export")
public void exportExcel(SomeParam param) {
}
public class SomeParam {
// 这个是原有的,有 get set 方法
private String field1;
// 这个是新增的,没有get set 方法 🎈。 问题就出在这里。
private String field2;
}
处理 Http Body 的数据。解析注解 RequestBody 的参数。
针对 MimeType 为 application/json 的请求,按照json 格式进行反序列化。
默认参数处理器 MappingJackson2HttpMessageConverter
string 反序列化为对象,使用的是 com.fasterxml.jackson.databind.ObjectMapper。
上述工程中,对 ObjectMapper 开启 private 属性检测。新增的属性可以正常反序列化。
ObjectMapper mapper = new ObjectMapper();
mapper.setVisibility(PropertyAccessor.ALL, Visibility.NONE);
mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
参考: [Jackson - Decide What Fields Get (De)Serialized | Baeldung](https://www.baeldung.com/jackson-field-serializable-deserializable-or-not) |
如果没有 setter 方法,jackson 会操作 field 来完成赋值。
/**
* This concrete sub-class implements property that is set directly assigning to a Field.
*/
public final static class FieldProperty extends SettableBeanProperty {
@Override
public final void set(Object instance, Object value) throws IOException {
try {
_field.set(instance, value);
} catch (Exception e) {
_throwAsIOE(e, value);
}
}
}
自定义 Class 参数解析
通过解析 request parameters, 用来构造和初始化对应的方法入参。
主要通过 ServletRequestDataBinder.bind(request) 来完成。
/**
* Apply given property values to the target object.
* By default, unknown fields will be ignored.
*
* @see org.springframework.validation.DataBinder#applyPropertyValues
*/
protected void applyPropertyValues(MutablePropertyValues mpvs) {
try {
// Bind request parameters onto target object.
// 默认使用 BeanWrapperImpl.setPropertyValue()
getPropertyAccessor().setPropertyValues(mpvs, isIgnoreUnknownFields(), isIgnoreInvalidFields());
}
catch (PropertyBatchUpdateException ex) {
// Use bind error processor to create FieldErrors.
}
}
public void setPropertyValues(PropertyValues pvs, boolean ignoreUnknown, boolean ignoreInvalid) throws BeansException {
// 通过遍历 request parameters 来尝试对 target 进行赋值
List<PropertyValue> propertyValues = (pvs instanceof MutablePropertyValues ? ((MutablePropertyValues) pvs).getPropertyValueList() : Arrays.asList(pvs.getPropertyValues()));
for (PropertyValue pv : propertyValues) {
try {
// etPropertyValue 使用 JDK 的 Introspector 来进行序列化操作。
// 没有setter 方法,自然没法赋值。
setPropertyValue(pv);
}
}
}
Servlet 规范中,针对如何处理异常,有对应的定义。
Tomcat 是一个Servlet容器,对于Servlet 规范的一种实现。
Servlet 3.0 引入了 Servlet 容器初始化时的可插拔性,允许开发者通过
ServletContainerInitializer
接口注册组件,这为 SpringBoot 框架集成提供了便利。SpringBoot 集成功能就包括自动注册异常处理,无需开发者关注容器层配置。
以下分别从Servlet 规范,Tomcat 实现,SpringBoot 默认配置三层来分析。
在Servlet规范中,
error-page
元素用于定义如何处理在Web应用程序中发生的错误。这个元素可以在 web.xml 部署描述文件中配置,用于指定特定错误代码或 Java 异常类型的处理页面。
error-page
的示例-HTTP状态码<!-- 当发生404错误时,会显示/error-404.html页面。-->
<error-page>
<error-code>404</error-code>
<location>/error-404.html</location>
</error-page>
error-page
的示例-Java异常类型<!-- 当发生NullPointerException时,会显示/error-help.html页面。-->
<error-page>
<exception-type>java.lang.NullPointerException</exception-type>
<location>/error-help.html</location>
</error-page>
error-page 解析和实例化过程,参考: org.apache.catalina.core.StandardContext#addErrorPage
SpringBoot 初始化的过程中,会调用 addErrorPage,进行默认异常配置
error-page 配置的执行过程,就是服务器内部转发的过程。 即 RequestDispatcher.forward(request, response)
/**
* Return the error page entry for the specified Java exception type,
* if any; otherwise return <code>null</code>.
*
* @see org.apache.catalina.core.StandardContext#findErrorPage(java.lang.String)
*/
@Override
public ErrorPage findErrorPage(String exceptionType) {
synchronized (exceptionPages) {
return (exceptionPages.get(exceptionType));
}
}
/**
* 处理HTTP状态码或Java异常,转发到指定的 errorPage 页面。
*
* @param errorPage The errorPage directive we are obeying
* @see org.apache.catalina.core.StandardHostValve#custom
*/
private boolean custom(Request request, Response response, ErrorPage er rorPage) {
try {
// Forward control to the specified location
ServletContext servletContext = request.getContext().getServletContext();
RequestDispatcher rd = servletContext.getRequestDispatcher(errorPage. getLocation());
// 熟悉的 servlet 操作
if (response.isCommitted()) {
// 它用于在当前响应中包含另一个资源的内容。调用 include 方法不会清除响应缓冲区,当前Servlet的输出和被包含资源的输出将一起发送给客户端。
rd.include(request.getRequest(), response.getResponse());
} else {
// 它用于将请求完全转发到另一个资源。新的资源将处理整个请求,并且原始请求的响应将被丢弃。
rd.forward(request.getRequest(), response.getResponse());
}
// Indicate that we have successfully processed this custom page
return (true);
}
}
Spring Boot 提供默认的错误处理机制,BasicErrorController
。通过类似 Tomcat 添加error-page配置的方式,把默认的 ErrorPage 注册到 Java Web 容器中.
对应的异常处理器是 BasicErrorController ,转发到当前Controller 的异常,返回 ResponseEntity<>(exception, status) 给前台。
参考:org.springframework.boot.autoconfigure.web.BasicErrorController#error
/**
* configures the container's error pages.
* @see org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration
*/
private static class ErrorPageCustomizer implements ErrorPageRegistrar, Ordered {
@Override
public void registerErrorPages(ErrorPageRegistry errorPageRegistry) {
// 默认使用/error, @see BasicErrorController's RequestMapping("${server.error.path:${error.path:/error}}")
ErrorPage errorPage = new ErrorPage(this.properties.getServletPrefix() + this.properties.getError().getPath());
// 约等于 Tomcat container.addErrorPages
errorPageRegistry.addErrorPages(errorPage);
}
}
在SpringMVC中,
@ExceptionHandler
注解用于处理 Controller 中的特定异常。Controller 发生注解指定的异常类型时,调用这个方法来处理异常。
配置示例:
@Controller
public class MyController {
@ExceptionHandler(value = { NullPointerException.class })
public ModelAndView handleMyCustomException(NullPointerException ex) {
ModelAndView mav = new ModelAndView();
mav.addObject("error", ex.getMessage());
mav.setViewName("errorPage");
return mav;
}
}
error-page 属于 Servlet 规范,各种 Java Web 容器都有对应的实现
SpringBoot 在初始化 web 环境过程中,就默认注册了异常处理,当应用异常发生时,托底处理,返回错误信息。
SpringMVC 的异常处理配置是框架内部的机制,即使 SpringMVC 无法处理的异常,还有 SpringBoot 配置到 Web 容器(Tomcat)中的异常处理。
如果SpringBoot 异常处理 BasicErrorController 发生了异常,还有Web 容器(Tomcat)默认的异常处理机制来兜底。
全局的异常处理设计,使得异常处理逻辑与业务逻辑分离,有助于代码的维护和清晰性。
从 error-page 在各层次的配置和作用原理中,又可以学习到架构分层的思想。
在应用工程里,存在一个后台执行 SQL 脚本的接口。在大量的使用后,提示异常:
Could not create connection to database server. Attempted reconnect 3 times. Giving up.
同时,其他连接这个 mysql 的应用,也提示异常。执行 SQL 使用数据库连接池,理论上不应该出现连接超量的情况。
// 通过指定数据源,执行对应的 update SQL
@PostMapping("/update/{dataSource}/{count}")
public ResponseResult<String> update(@PathVariable String dataSource, @PathVariable Integer count , @RequestBody String sql) {
// 获取数据源
Map<String, DataSource> dataSourceMap = getDataSources();
DataSource source = dataSourceMap.get(dataSource);
// 执行
return ResponseResult.ok(sqlService.execute(source,sql,count));
}
代码比较简单,猜测可能有以下两个问题:
在执行过程中,没有close connection,导致连接池一直在创建新的 connection。
获取数据源的过程中,存在数据源对象泄露,导致创建了很多 DataSource。
jdbc 关闭连接的顺序是:ResultSet → Statement → Connection
在工程代码中,没有发现手动关闭 Statement 的操作。是不是会阻塞 connection close ?
/**
* HikariDataSource Connection 会自动关闭 Statement
* @see com.zaxxer.hikari.pool.ProxyConnection This is the proxy class for java.sql.Connection.
*/
@Override
public final void close() throws SQLException {
// automatic resource cleanup
closeStatements();
if (delegate != ClosedConnection.CLOSED_CONNECTION) {
try {
// fall back operation...
if (isCommitStateDirty && !isAutoCommit) {
delegate.rollback();
}
}
finally {
// return resource
delegate = ClosedConnection.CLOSED_CONNECTION;
poolEntry.recycle(lastAccess);
}
}
}
// 关闭 statement, 会级联关闭 results
// @see com.mysql.jdbc.StatementImpl#realClose
protected synchronized void realClose(boolean calledExplicitly, boolean closeOpenResults) throws SQLException {
if (closeOpenResults) {
if (this.results != null) {
try {
this.results.close();
} catch (Exception var4) {
}
}
this.closeAllOpenResults();
}
}
/**
* We only create connections if we need another idle connection or have threads still waiting
* for a new connection. Otherwise we bail out of the request to create.
*
* @return true if we should create a connection, false if the need has disappeared
* @see com.zaxxer.hikari.pool.HikariPool.PoolEntryCreator#shouldCreateAnotherConnection
*/
private synchronized boolean shouldCreateAnotherConnection() {
// The property controls the maximum number of connections that HikariCP will keep in the pool, including both idle and in-use connections.
return getTotalConnections() < config.getMaximumPoolSize() &&
(connectionBag.getWaitingThreadCount() > 0 || getIdleConnections() < config.getMinimumIdle());
}
终于理解Spring Boot 为什么青睐HikariCP了,图解的太透彻了!
源码结合实践,数据库连接池肯定不会无限制创建连接。
public Map<String, DataSource> getDataSources(){
// 没有缓存或者单例处理,每调用一次,初始化一次 DataSource 😅
Map<String, DataSource> dataSources = new HashMap<>();
datadaSourcesConfig.forEach((k, v) -> {
// spring boot, Convenience class for building a DataSource with common implementations and properties.
DataSourceBuilder builder = DataSourceBuilder.create();
builder.url(v.getUrl());
builder.username(v.getUsername());
builder.password(v.getPassword());
DataSource dataSource = builder.build();
dataSources.put(k, dataSource);
});
return dataSources;
}
先入为主的想法,会让排查思路受困,不能快速找到问题。例如:出现too many connections
异常,就直接认为是使用 connection 过程中有问题,其实是创建了太多的连接池,间接持有了很多 connection 导致的。
对于所使用的 DataSource,以及 jdbc 底层的执行机制不了解,导致排查过程中疑点众多,不能快速定位。
从线上的异常堆栈也能看出来,是初始化数据源过程抛出异常。因为不了解 HikariDataSource 工作机制,忽略这个重要信息。
框架和工具的自动化处理,让编程工作变得更加方便。例如: jdbc connection close 模板代码,已经没有必要。
Could not create connection to database server. Attempted reconnect 3 times. Giving up. – mysql-connector-java-5.1.47.jar
// 启用自动连接,HighAvailability
private BooleanConnectionProperty autoReconnect = new BooleanConnectionProperty("autoReconnect", false, Messages.getString("ConnectionProperties.autoReconnect"), "1.1", HA_CATEGORY, 0);
// 最多尝试连接次数,默认 3 次。
private IntegerConnectionProperty maxReconnects = new IntegerConnectionProperty("maxReconnects", 3, 1, Integer.MAX_VALUE, Messages.getString("ConnectionProperties.maxReconnects"), "1.1", HA_CATEGORY, 4);
// 异常提示配置 mysql-connector-java-5.1.47.jar!\com\mysql\jdbc\LocalizedErrorMessages.properties
Connection.UnableToConnectWithRetries=Could not create connection to database server. \
Attempted reconnect {0} times. Giving up.
// autoReconnect
public void createNewIO(boolean isForReconnect) throws SQLException {
synchronized (getConnectionMutex()) {
// jdbc.url autoReconnect 指定为 true,识别为 HighAvailability
if (!getHighAvailability()) {
connectOneTryOnly(isForReconnect, mergedProps);
return;
}
// maxReconnects 默认为 3,重试失败的提示就是: Attempted reconnect 3 times. Giving up.
connectWithRetries(isForReconnect, mergedProps);
}
}
-- 查看最大连接数
show variables LIKE "max_connections"
-- 修改最大连接数(临时修改)
set GLOBAL max_connections=1024
About Pool Sizing · brettwooldridge/HikariCP Wiki · GitHub
Even a computer with one CPU core can “simultaneously” support dozens or hundreds of threads. But we all [should] know that this is merely a trick by the operating system though the magic of time-slicing. 并发和并行的区别。
In reality, that single core can only execute one thread at a time; then the OS switches contexts and that core executes code for another thread, and so on. 多线程的切换是有代价的。
When we look at what the major bottlenecks for a database are, they can be summarized as three basic categories: CPU, Disk, Network. 数据库性能分析点
On a server with 8 computing cores, setting the number of connections to 8 would provide optimal performance, and anything beyond this would start slowing down due to the overhead of context switching. 理想情况
And it is during this time that the OS could put that CPU resource to better use by executing some more code for another thread. So, because threads become blocked on I/O, we can actually get more work done by having a number of connections/threads that is greater than the number of physical computing cores. 综合各种情况,需要一套公式和压测,决定如何充分发挥服务器性能
相对于单例数据库的查询操作,分布式数据查询会有很多技术难题。
本文记录 Mysql 分库分表 和 Elasticsearch Join 查询的实现思路,学习分布式场景数据处理的设计思路。
分库分表场景下,查询语句如何分发,数据如何组织。相较于NoSQL 数据库,Mysql 在SQL 规范的范围内,相对比较容易适配分布式场景。
基于 sharding-jdbc 中间件的方案,了解整个设计思路。
sharding-jdbc 代理了原始的 datasource, 实现 jdbc 规范来完成分库分表的分发和组装,应用层无感知。
执行流程:SQL解析 => 执行器优化 => SQL路由 => SQL改写 => SQL执行 => 结果归并 io.shardingsphere.core.executor.ExecutorEngine#execute
Join 语句的解析,决定了要分发 SQL 到哪些实例节点上。对应SQL路由。
SQL 改写就是要把原始(逻辑)表名,改为实际分片的表名。
复杂情况下,Join 查询分发的最多执行的次数 = 数据库实例 × 表A分片数 × 表B分片数
示例代码工程:git@github.com:cluoHeadon/sharding-jdbc-demo.git
/**
* 执行查询 SQL 切入点,从这里可以完整 debug 执行流程
* @see ShardingPreparedStatement#execute()
* @see ParsingSQLRouter#route(String, List, SQLStatement) Join 查询实际涉及哪些表,就是在路由规则里匹配得出来的。
*/
public boolean execute() throws SQLException {
try {
// 根据参数(决定分片)和具体的SQL 来匹配相关的实际 Table。
Collection<PreparedStatementUnit> preparedStatementUnits = route();
// 使用线程池,分发执行和结果归并。
return new PreparedStatementExecutor(getConnection().getShardingContext().getExecutorEngine(), routeResult.getSqlStatement().getType(), preparedStatementUnits).execute();
} finally {
JDBCShardingRefreshHandler.build(routeResult, connection).execute();
clearBatch();
}
}
启用 sql 打印,直观看到实际分发执行的 SQL
# 打印的代码,就是在上述route 得出 ExecutionUnits 后,打印的
sharding.jdbc.config.sharding.props.sql.show=true
sharding-jdbc 根据不同的SQL 语句,会有不同的路由策略。我们关注的 Join 查询,实际相关就是以下两种策略。
StandardRoutingEngine binding-tables 模式
ComplexRoutingEngine 最复杂的情况,笛卡尔组合关联关系。
-- 参数不明,不能定位分片的情况
select * from order o inner join order_item oi on o.order_id = oi.order_id
-- 路由结果
-- Actual SQL: db1 ::: select * from order_1 o inner join order_item_1 oi on o.order_id = oi.order_id
-- Actual SQL: db1 ::: select * from order_1 o inner join order_item_0 oi on o.order_id = oi.order_id
-- Actual SQL: db1 ::: select * from order_0 o inner join order_item_1 oi on o.order_id = oi.order_id
-- Actual SQL: db1 ::: select * from order_0 o inner join order_item_0 oi on o.order_id = oi.order_id
-- Actual SQL: db0 ::: select * from order_1 o inner join order_item_1 oi on o.order_id = oi.order_id
-- Actual SQL: db0 ::: select * from order_1 o inner join order_item_0 oi on o.order_id = oi.order_id
-- Actual SQL: db0 ::: select * from order_0 o inner join order_item_1 oi on o.order_id = oi.order_id
-- Actual SQL: db0 ::: select * from order_0 o inner join order_item_0 oi on o.order_id = oi.order_id
首先,对于 NoSQL 数据库,要求 Join 查询,可以考虑是不是使用场景和用法有问题。
然后,不可避免的,有些场景需要这个功能。Join 查询的实现更贴近SQL 引擎。
基于 elasticsearch-sql 组件的方案,了解大概的解决思路。
这是个elasticsearch 插件,通过提供http 服务实现类 SQL 查询的功能,高版本的elasticsearch 已经具备该功能
因为 elasticsearch 没有 Join 查询的特性,所以实现 SQL Join 功能,需要提供更加底层的功能,涉及到 Join 算法。
源码地址:git@github.com:NLPchina/elasticsearch-sql.git
/**
* Execute the ActionRequest and returns the REST response using the channel.
* @see ElasticDefaultRestExecutor#execute
* @see ESJoinQueryActionFactory#createJoinAction Join 算法选择
*/
@Override
public void execute(Client client, Map<String, String> params, QueryAction queryAction, RestChannel channel) throws Exception{
// sql parse
SqlElasticRequestBuilder requestBuilder = queryAction.explain();
// join 查询
if(requestBuilder instanceof JoinRequestBuilder){
// join 算法选择。包括:HashJoinElasticExecutor、NestedLoopsElasticExecutor
// 如果关联条件为等值(Condition.OPEAR.EQ),则使用 HashJoinElasticExecutor
ElasticJoinExecutor executor = ElasticJoinExecutor.createJoinExecutor(client,requestBuilder);
executor.run();
executor.sendResponse(channel);
}
// 其他类型查询 ...
}
三种 Join 算法:Nested Loop Join,Hash Join、 Merge Join
MySQL 只支持 NLJ 或其变种,8.0.18 版本后支持 Hash Join
NLJ 相当于两个嵌套循环,用第一张表做 Outter Loop,第二张表做 Inner Loop,Outter Loop 的每一条记录跟 Inner Loop 的记录作比较,最终符合条件的就将该数据记录。
Hash Join 分为两个阶段; build
构建阶段和 probe
探测阶段。
可以使用Explain 查看使用哪种 Join 算法。
EXPLAIN FORMAT=JSON
SELECT * FROM
sale_line_info u
JOIN sale_line_manager o ON u.sale_line_code = o.sale_line_code;
// 使用Jquery 发送 json 数据
$.ajax({
url: "some.php",
type: "POST",
dataType:"json",
contentType:"application/json",
data: JSON.stringify({ name: "John", location: "Boston" }),
success: function (msg) {
alert( "Data Saved: " + msg );
}
});
// 默认类型 from ajaxSettings
// 遵从业界规范,如果使用 curl --data,默认也是 Content-Type: application/x-www-form-urlencoded
contentType: "application/x-www-form-urlencoded; charset=UTF-8",
数据(object)默认按照表单传参格式(key/value 键值对)来序列化数据。
如果后台接收 json 格式数据,需要调用 JSON.stringify() 处理为 string。
object 如果不处理为string,会按照 object.toString() 来处理。发送的数据是:[object Object]
Get 请求,参数会追加到URL。Post 请求,数据通过请求体 (body) 发送到服务端。
// Convert data if not already a string
// processData 默认 true
// jQuery.param 处理的数据会调用 encodeURIComponent encoded
if ( s.data && s.processData && typeof s.data !== "string" ) {
s.data = jQuery.param( s.data, s.traditional );
}
// 序列化结果: a=bc&d=e%2Cf
$.param({ a: "bc", d: "e,f" })
// 序列化结果: a%5B%5D=1&a%5B%5D=2, 也就是 a[]=1&a[]=2
$.param({ a: [1,2] })
XMLHttpRequest
(XHR) objects are used to interact with servers.You can retrieve data from a URL without having to do a full page refresh. This enables a Web page to update just part of a page without disrupting what the user is doing.
// 使用 jqXHR 包装真正的 xhr, 把xhr 的事件包装为一些事件和接口,方便开发
jQuery.ajaxSettings.xhr = function() {
try {
return new window.XMLHttpRequest();
} catch ( e ) {}
};
Jquery 对于指定的 dataType, 会尝试进行反序列化的转换。
支持的类型包括:html json xml script
数据类型转换在
function ajaxConvert
完成。
// Data converters. Http 请求返回的文本,可以根据 dataType 和支持,进行转换
// 配置格式约定:Keys separate source (or catchall "*") and destination types with a single space
converters: {
// Convert anything to text
"* text": String,
// Text to html (true = no transformation)
"text html": true,
// Evaluate text as a json expression。 常用的 json ,就是这样处理的。🎈
"text json": JSON.parse,
// Parse text as xml
"text xml": jQuery.parseXML,
"text script": function( text ) {
jQuery.globalEval( text );
return text;
}
},
// Timeout 配置,通过定时器实现
if ( s.async && s.timeout > 0 ) {
// 发起请求前,添加一个定时器,到期发起取消操作
timeoutTimer = window.setTimeout( function() {
// jqXHR 作为包装类,真正的XHR 命名为 transport。包装类可以提供更加丰富的接口,流程可控。
jqXHR.abort( "timeout" );
}, s.timeout );
}
// 无论请求结果怎样,都会调用 done(), 会移除定时器
// Clear timeout if it exists
if ( timeoutTimer ) {
window.clearTimeout( timeoutTimer );
}
关于Jquery 数据传递,浏览器数据传输的疑问,可以通过 Curl 发送请求,查看具体的HTTP 协议数据格式来确认。
# 描述: use the curl command with –data and –data-raw options to send text over a POST request:
$ website="https://webhook.site/5610141b-bd31-4940-9a83-1db44ff2283e"
# used the –trace-ascii option to trace the requests and capture the trace logs in the website-data.log and website-data-raw.log files.
$ curl --data "simple_body" --trace-ascii website-data.log "$website"
$ curl --data-raw "simple_body" --trace-ascii website-data-raw.log "$website"
# 结果:website-data-raw.log:0083: Content-Type: application/x-www-form-urlencoded
$ grep --max-count=1 Content-Type website-data-raw.log website-data.log
参考:[How to Post Raw Body Data With cURL | Baeldung](https://www.baeldung.com/curl-post-raw-body-data) |