####前言 有时候因为需要我们不得不使用`XMLHttpRequest`的同步请求，但是同步请求不能设置`responseType`为`arraybuffer`，否则会报错： > Failed to set the 'responseType' property on 'XMLHttpRequest': The response type cannot be changed for synchronous requests made from a document. 那么我们该如何接收`arraybuffer`呢？ ####解决 查找资料不难发现，不设置`responseType`的同步请求默认返回的是`ascii`编码的`string`。 我们可以直接将`string`通过下面的方法转为`arraybuffer`吗： ``` Uint8Array.from(xhr.response, c => c.charCodeAt(0)); ``` 结论就是：如果你服务器端返回的本来就是`ascii`编码的数据，那么是没有问题的，如果是非`ascii`编码的数据，那么接收到的数据就不是原始数据。 所有`大于127`的字节将全部变成`253`。 那么就没有办法了吗？办法还是有的： 将`mime-type`中的`charset`设置成`x-user-defined`即可。 ``` xhr.overrideMimeType('text/plain; charset=x-user-defined'); ``` 然后再使用`Uint8Array.from(xhr.response, c => c.charCodeAt(0));`转换获取到的就是原始的字节数据了。
SpringMvc Redirect 重定向 URL 中携带 Model 参数问题
看文档说明`spring.mvc.ignore-default-model-on-redirect`默认是`true`，也就是说：默认就是`Redirect`时会忽略`model`中的属性。 但我是在`HandlerInterceptor.postHandle`中给`model`添加的值。发现这个时候`spring.mvc.ignore-default-model-on-redirect`根本就不起作用，可能`postHandle`参数中的`model`并不是`default model`（文档中说的是忽略 default model，至于"default"到底指的是什么懒得深究了）。 解决办法也比较简单： 因为`Redirect URL`中多余的参数是拦截器中添加的，那么在`postHandle`判断下`Response`的状态即可。 比如：`response.status != 302`时才给`model`中添加数据。
Spring Cloud Stream 3.x 使用：Supplier / Consumer / Function
####前言 之前用的`Spring Cloud Stream 2.x`，一切都没什么问题，最近升级了`3.x`，发现用得比较混乱，不管是国内外，发现用的都比较少，遇到问题搜索几乎都没有答案，可能是这东西还是不够成熟，所以整理一下思路。 ####问题 由于`3.x`直接废弃了很多的类，比如：`@StreamListener`、`@Input`、`@Output`等。 虽然被标记为`Deprecated`依旧可用使用，但是我们还是选择使用最新用法为好。 没有了`@StreamListener`，那么我们用什么替代呢，答案就是函数式编程：`Consumer / Function`。 #####Consumer 顾名思义，它就是用来消费的，之前消费是通过`@StreamListener`注解来实现的，但是`2.x`版本是不支持批量消费的，如果你有批量消费的需求，还是尽快升级到`3.x`版本，并且使用最新特性，因为`3.x`版本`@StreamListener`虽然依旧可用，但还是不支持批量消费的。 用法介绍： ``` @Bean public Consumer handle() { return list -> { //do something }; } ``` 通过定义如上`Bean`，就是一个消费者，对应的消费通道是`handle-in-0`，规则就是函数名称 + `in` + `递增的数字`。 对应的`binding`配置为：`spring.cloud.stream.bindings.handle-input-0` #####Function 从函数类型来看，它是个接收一个输入、一个输出的函数，因此，它既是生产者也是消费者，用法如下： ``` @Bean public Function uppercase() { return value -> value.toUpperCase(); } ``` 它是一个很简单的程序，就是将输入转为大写然后再输出，对应两个通道：`uppercase-input-0`、`uppercase-output-0`，命名规则同`Consumer`。 #####Supplier 最后再说下`Supplier`，前面两个可以说是用来消费的，虽然`Function`也有生产端，但是很多时候我们是不需要先接收消息，而是直接将消息发送到对应主题上。 用法介绍： ``` @Bean Supplier hhh() { return () -> "ssssss"; } ``` 但是我感觉对于我目前而言，不知道该用在什么地方，因为它是类似于定时器，默认是每秒触发一次，这里简单的返回一个字符串，可以替换成其他动态获取数据的接口，然后将数据发送到`hhh-output-0`通道。 然而我们更多的时候是需要主动的发送消息，类似于`KafkaTemplate.send()`方法直接调用。 但是在这里不得不说下，如果你引入了`Spring Cloud Stream Kafka Binder`那么你是可以直接使用`KafkaTemplate`的。 一旦你使用`KafkaTemplate`，那么配置文件中就要多出一项配置`spring.kafka.bootstrap-server`，注意它不是在`spring.cloud`下，因此可以说它是和`Spring Cloud Stream`完全隔离的，所以你不仅要配置`spring.cloud.stream.kafka.binder.brokers`还要配置`spring.kafka.bootstrap-server`，是不是感觉怪怪的。 之前我从`2.x`升级到`3.x`发送消息就是用的`KafkaTemplate`，结果发现部署的时候，配置了`spring.cloud.stream.kafka.binder.brokers`还一直报`localhost/9092`连不上，明明配置的另一台机器的`ip`，可它还是去连`localhost`，后来才发现是使用`KafkaTemplate`的问题。 上面说了`Supplier`，是不是感觉有点鸡肋，要想类似`KafkaTemplate`发送消息该怎么做呢？ #####StreamBridge 用法同`KafkaTemplate`类似，在需要的地方之间`@Autowired`引入即可，发送消息`send`接口： ``` public boolean send(String bindingName, Object data) { return this.send(bindingName, data, MimeTypeUtils.APPLICATION_JSON); } ``` 使用： > streamBridge.send("message-out", "hello world"); 如果你直接这样使用，会报如下错误： ``` Caused by: java.lang.NullPointerException: Cannot invoke "java.util.function.Function.apply(Object)" because "functionToInvoke" is null ``` 我不知道这是目前版本的`BUG`还是哪里没有配置的问题，官方说它能够发送任何对象消息，但我测试任何类型都发送不出去。 虽然它默认的`contentType`为`application/json`，并且也有默认的`MappingJackson2MessageConverter`消息转换器。 定位错误日志，发现它会去找一个`Function functionToInvoke`，为什么去找一个`Function`这点我没弄明白，因为前面我们知道了`Function`是个接收一个输入一个输出的函数。 如果我们类似地去定义一个`Function`的`Bean`，发现不再报错了，但是一旦定义一个`Function`的`Bean`，就多了一个输入与一个输出主题，会自动地创建在`Kafka`中，我们只是简单地想调用`StreamBridge.send`发送消息到指定通道而已，如果说`Function`作为一个类型转换器，那么大可不必要，`MappingJackson2MessageConverter`是干嘛的？ 好了，暂且不说这个类，就当它是个`BUG`或者使用不当吧，搜索也搜索不出个什么答案。那么退而求其次吧，还有个办法能让它正常工作，它有个配置叫`spring.cloud.stream.kafka.bindings.channel-name.producer.useNativeEncoding`，默认值为`false`，我们将它改为`true`。 然后`streamBridge`如下发送消息： ``` streamBridge.send("message-out", "aaaa".getBytes()); ``` 其中`aaaa`一般为对象序列化后的`json`字符串。 ####总结 主要还是将`Spring Cloud Stream`与`Spring kafka`区分开来使用，统一使用其中一种，如实混合使用的话总感觉怪怪的，但是`StreamBridge`到底该如何直接发送对象消息，官方说要做额外配置，但具体又没有给出，并且官方`Demo`也没看到做什么配置，真是脑瓜子疼啊。 最后说下一个项目中定义多个`Supplier / Consumer / Function`的问题，如果不做配置，那么定义多个`Supplier / Consumer / Function`时，你会发现一个都不生效了，`Spring`会把他们都当成普通的`Bean`处理。 因此，在整个项目只有一个`Supplier / Consumer / Function`的时候，`Spring`会把它默认地当做消息函数来处理，如果有多个，为了能更好的区分消息与普通的`Bean`，需要在配置文件中指定：`spring.cloud.function.definition=aaa;bbb;ccc` 其中`aaa`、`bbb`、`ccc`为消息处理函数。
升级 Spring Cloud Stream Kafka 至 3.x 支持批量消费的几个问题
####前言 前几年在使用`Spring Cloud Stream Kafka`的时候发现并不支持批量消费，当时在`Github`上看到批量消费已经放在计划中了。 最近看了下在`3.x`版本中已经支持了，于是，就得升级各种依赖了。 ####问题 之前使用的是`Sping Boot 2.1.9.RELEASE`版本 + `Spring Cloud Greenwich.RELEASE`版本。 想要将`Spring Cloud Stream Kafka`升级至`3.x`版本，就得升级`Spring Cloud`的版本。 官网查看一下，最终决定升级至`2020.0.2`版本，升级`Spring Cloud`版本时，注意`Spring Boot`的版本，因为两者可能会有各种兼容问题，最简单直接的办法就是直接打开`Spring Cloud`的官方文档，最最开头会有对应的`Spring Boot`的版本。 我这里`Spring Boot`的版本选择`2.4.3`。 一切准备就绪后，就是改写原来的代码了。 之前的做法是使用`@StreamListener`注解实现消费，查看`3.x`的版本会发现，这个注解已经被标记为`@Deprecated`了。 最新的做法就是函数式编程，具体参见文档吧。 首先配置文件`consumer`属性下`batch-mode: true`开启批量模式。 批量消费直接定义一个返回`Consumer`类型的`Bean`即可，如下： ``` @Bean public Consumer handle() { return message -> { Acknowledgment acknowledgment = message.getHeaders().get(KafkaHeaders.ACKNOWLEDGMENT, Acknowledgment.class); List torrents = message.getPayload(); //save to mongodb and index to es torrentService.upsertAndIndex(torrents); //no error, execute acknowledge if (acknowledgment != null) { acknowledgment.acknowledge(); } }; } ``` 他对应的`binder id`为函数名`handle` + `-` + `0`，最后面的`0`是自增的（多个输入，使用`Function`函数时，具体参见文档吧）。 但是，如果我有一个`Topic`，想要两个不同的`Group`消费，注意：这两个`Group`是互不相干的。官方只给出了使用`Function`函数式接收多个输入的做法。 如果你想这还不简单，直接再加个`Consumer`的函数呗，结果你会发现，消费函数失效了，并且控制台并没有打印错误日志。 这个问题困扰了我好久，一直以为是使用不当导致的，后来注意到控制台启动时的`INFO`日志，说不能定义多个函数式`Bean`，要使用的话必须在配置文件中配置。 具体做法就是一个配置`spring.cloud.function.definition=函数名1;函数名2...`。 升级了`Spring Cloud`和`Spring Boot`，项目中的`Spring Data Elasticsearch`、`Spring Data Mongodb`均出现了问题，后面再继续讲怎么填坑吧。
htmlcompressor 压缩 html 报错 UT010006: Cannot call getWriter(), getOutputStream() already called 解决办法
最近，使用`thymeleaf`模板语言的时候，发现输出的`html`网页中会出现很多的空行，不像`JSP`那样可以通过在`web.xml`里配置一下就能去掉多余的空行。 `Github`上作者好像也不愿做此功能，需要使用者自己去实现。 于是找到`Google`的`htmlcompressor`，使用很简单，就是自定义`Filter`，在`Filter`里将`html`的空行去掉，实现如下，自定义`CompressResponseFilter.kt`: ```java @WebFilter(filterName = "CompressResponseFilter", urlPatterns = ["/*"]) class CompressResponseFilter : Filter { private var compressor: HtmlCompressor? = null @Throws(IOException::class, ServletException::class) override fun doFilter(req: ServletRequest, resp: ServletResponse, chain: FilterChain) { val responseWrapper = CharResponseWrapper( resp as HttpServletResponse) chain.doFilter(req, responseWrapper) val servletResponse = responseWrapper.toString() resp.getWriter().write(compressor!!.compress(servletResponse)) } @Throws(ServletException::class) override fun init(config: FilterConfig) { compressor = HtmlCompressor() compressor!!.isCompressCss = true compressor!!.isCompressJavaScript = false } override fun destroy() {} } ``` 我这里是`Kotlin`代码，相信大家也能看得懂。 里面的`CharResponseWrapper.kt`： ```java class CharResponseWrapper(response: HttpServletResponse) : HttpServletResponseWrapper(response) { private val output: CharArrayWriter = CharArrayWriter() override fun toString(): String { return output.toString() } override fun getWriter(): PrintWriter { return PrintWriter(output) } } ``` 需要引入的`jar`包： ```xml com.yahoo.platform.yui yuicompressor 2.4.6 com.googlecode.htmlcompressor htmlcompressor 1.5.2 ``` 如果是`SpringBoot`项目，需要开启`Filter`的扫描： ``` @ServletComponentScan(basePackages = ["com.bde4.v2.filter"]) ``` 可能你`Google`到的也就是我上面的实现方法，但是，运行后会发现报错： > UT010006: Cannot call getWriter(), getOutputStream() already called 仔细查看错误日志不难发现报错的都是`css`、`js`、图片什么的请求。 跟进错误，发现抛异常的代码： ```java if (this.responseState == HttpServletResponseImpl.ResponseState.STREAM) { throw UndertowServletMessages.MESSAGES.getOutputStreamAlreadyCalled(); } ``` `this.responseState`是个私有属性，然而并没有`getter`或者`setter`方法，最多也只能找到一个`reset`方法将`this.responseState`重置： ```java public void reset() { if (this.servletOutputStream != null) { this.servletOutputStream.resetBuffer(); } this.writer = null; this.responseState = HttpServletResponseImpl.ResponseState.NONE; this.exchange.getResponseHeaders().clear(); this.exchange.setStatusCode(200); this.treatAsCommitted = false; } ``` 于是在`resp.getWriter().write(compressor!!.compress(servletResponse))`之前调用`resp.reset()`重启项目，此时报错`Response already commited`。 所以很明显，对于静态资源，在调用`chain.doFilter(req, responseWrapper)`时，它就已经将数据刷到输出流里面去了。 所以再次调用`resp.getWriter().write(compressor!!.compress(servletResponse))`就会报错： > UT010006: Cannot call getWriter(), getOutputStream() already called 解决办法就是： 在`chain.doFilter(req, responseWrapper)`之后（注意，必须是之后，之前的话`response`的状态还是未提交的）添加判断条件： ```java override fun doFilter(req: ServletRequest, resp: ServletResponse, chain: FilterChain) { val responseWrapper = CharResponseWrapper( resp as HttpServletResponse) chain.doFilter(req, responseWrapper) val servletResponse = responseWrapper.toString() if (!resp.isCommitted) resp.getWriter().write(compressor!!.compress(servletResponse)) } ``` `if (!resp.isCommitted)`只有当`response`未提交的时候我们才去压缩`html`。 对于这个错误，网上搜了很多文章，都没有找到解决办法，如有遇到相同问题的朋友，希望这篇文章能够帮到你。 总的来说就是过滤掉静态资源文件，可能有人会说过滤静态资源用`URL`的后缀就行了，但是为了保险起见还是用上面的方法比较合适，因为报错的根本原因就是`response`的状态为已提交。
Kotlin + SpringBoot 2.2.2 @Transactional注解导致 Service 类无法注入属性
小记一下，不知道是不是 BUG，反正现在都没找到解决办法。 有如下类： ```kotlin @Service open class MovieService { @Autowired lateinit var movieDao: MovieDao } ``` 当`MovieService`不管是类还是它的方法加上`@Transactional`后，里面的`dao`就无法注入了(注意，这里不是网上说的那些`MovieService`无法注入到其他地方了，而是它里面的属性无法注入)，这肯定是代理出了问题，`SpringBoot 2.x`默认使用`CGLIB`代理，暂时没去加个接口改用`JDK`自带的动态代理测试行不行。 最终暂时用手动的方式添加事务吧： ```kotlin @Configuration open class DataSourceConfigure { @Bean open fun dataSourceTransactionManager(datasource: DruidDataSource): DataSourceTransactionManager { return DataSourceTransactionManager(datasource) } } ``` 手动开启事务并提交事务： ```kotlin val transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition) dataSourceTransactionManager.commit(transactionStatus) ```
发表文章时上传图片或附件处理方案
####场景介绍 后台管理 -> 新建文章 -> 上传图片或者附件 -> 发布文章 这个过程就会有个问题，一般情况下，在新建文章的时候，在未点击发布之前数据库中是没有文章记录的，但是编写文章的时候可能就需要上传图片或者附件，大多数情况是上传图片和附件与发布文章是分开的两个步骤，而不是上传图片或附件与文章内容一并提交到服务器。 那么，最终图片或附件是如何与文章关联起来的？若是新建文章期间上传了图片，但是用户最终没有点击发布就关闭了浏览器，如果不进行处理，那么这些图片或附件就有可能成了服务器中的垃圾，永远都用不到了。 ####一种解决思路 新建如下表结构（Kotlin）： ```kotlin data class Attachment ( val id: Int, var path: String?, //附件路径 var uploadDate: Date?, //上传时间 var topicId: Int? = -1, //主题ID var type: Int, //类型，比如：封面图片(0) 内容图片(1) 附件(2) val userId: Int //用户ID ) : Serializable ``` 在新建文章时用户上传了附件，直接插入到`tb_attachment`表中去，只不过此时的`topicId`是默认值`-1`，也就是未关联状态，然后将附件ID返回给前端页面。 当用户点击发布时，假设主题ID是自增的，那么就在插入主题返回ID之后将主题ID更新到`tb_attachment`表中去，比如： > UPDATE tb_attachment SET topic_id = #{topicId} WHERE userId = #{userId} AND id IN(前端传过来的附件ID列表) 以上是一个正常的主题发布过程，下面讲一下对于上传图片但未发布主题情况的处理。 首先上传附件还是同上，不管用户点没点击发布按钮都将数据存进数据库，如果用户未发表就关闭了浏览器也没关系。 当用户下次新建文章时，可以查出未使用的附件： > SELECT * FROM tb_attachment WHERE userId = #{userId} AND topicId = -1 然后提示用户用这些已存在的附件可供选择使用。 假设用户一直都不使用，那我们就只能用定时任务来处理这些长时间不使用的附件了： > SELECT * FROM tb_attachment WHERE topicId = -1 AND NOW() - uploadDate > xxxxx(自定义超时删除时间) 最后就是删除磁盘文件以及数据库记录了。
SpringBoot 2.x 集成 Hibernate Search（历史项目迁移）
####前言 大学期间个人写了个网站，采用的是`Spring` + `SpringMVC` + `Spring Data JPA`开发，如今之际想把网站重构：干掉`Spring Data JPA`，换成`Mybatis`操作数据库，重写前端页面。由于最开始使用的是`Hibernate Search`作为网站的全文检索，并且目前暂时不想重写后台管理系统（保留后台的增删改查），于是有了今天问题： 1. SpringBoot 集成 Hibernate Search 2. Mybatis 与 Hibernate Search 混合开发是否冲突（因为 Hibernate Search 依赖 Spring Data JPA） 3. 如何继续使用原有的索引（即老后台可继续插入数据建立索引，新系统能够直接查询老系统建的索引） ####扯卵谈 上面问题看似简单，但是兜兜转转也报了不少错误，浪费了不少时间，所以博客记之。 首先，最开始的问题，不用多想就是版本问题，`Spring Boot`版本： ``` org.springframework.boot spring-boot-starter-parent 2.2.2.RELEASE ``` 因为`Hibernate Search`依赖于`Spring Data JPA`，于是引入`Spring Data JPA`： ``` org.springframework.boot spring-boot-starter-data-jpa ``` 自动引入的`hibernate-core`版本为： ``` org.hibernate hibernate-core 5.4.9.Final ``` 集成`Hibernate Search`我们只需要再引入`Hibernate-search-orm`依赖即可： ``` org.hibernate hibernate-search-orm 5.11.4.Final ``` 注意上面的版本是`5.11.4.Final`，这个版本是没问题的，那么我是如何找到这个可用的版本的呢？ 前面说了自动引入的`hibernate-core`版本为`5.4.9.Final`，那么我们到`https://mvnrepository.com`仓库去搜索`hibernate-search-orm`，可用看到有很多的版本，随便选一个版本点击进去，可用看到有这么一栏`Compile Dependencies`。 下面列举了一些编译依赖及其版本，可用看到目前最新版本`5.11.4.Final`正好依赖的是`5.4.9.Final`版本的`hibernate-core`，所以版本问题解决！ #####第二个问题：与 Mybatis 是否冲突？ 这个问题基本上不是什么问题，因为早前在公司的时候做过相关的测试，可用混合开发，不会冲突。 需要注意的是，与`Spring Boot`继承的`Spring Data JPA`在使用`@Column`时会发现设置的`name`参数不起作用，举例： > 数据库字段`createDate`，java 属性`createDate` 以前没有继承`Spring Boot`的时候，`@Column`的默认策略是`name`同`JAVA`属性。 但是集成`Spring Boot`后, `Column`的策略变为：`驼峰转下划线`,即： > createDate => create_date 解决办法： 1. 将列名全部改为小写 @column(name="testname") 2. 修改命名策略：spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl #####最后一个问题：共用老系统的索引 我之所以会有这个问题，是因为，重写后的项目，我更换了项目的包名，即：`Entity`实体类的包名发生了改变，即使我们把索引的存储路径配置为老系统保存的路径，比如： ``` spring: profiles: active: dev thymeleaf: cache: false mode: LEGACYHTML5 jpa: properties: hibernate: search: default: directory_provide: filesystem indexBase: E:\usr\local\indexes ``` 说明：`E:\usr\local\indexes`：老系统索引生成的目录。 打开目录其实不难发现，都是以`Entity`的全路径名作为索引文件的目录，所以一旦重写的项目包名发生了变化，那么就无法继续使用以前的索引了。 解决办法： 改包名？不太现实。 由于我的整个项目是采用`Kotlin`重写的，所以为了避免出现不必要的问题，决定直接将老系统的`Entity`拷贝过来，`Kotlin`可以与`JAVA`混合开发，正合我意。 注意：拷贝过来不是仅仅将`.java`文件拷贝过来，而是建立完全一样的包路径，将`.java`拷贝进去。 这样就行了吗？不行~！ 假如我的新项目`Application`的包名为`com.aaa`，老项目的包名为`com.bbb`，这个时候启动项目进行查询会发现报错： ``` org.hibernate.search.exception.SearchException: HSEARCH000331: Can't build query for type 'com.bbb.entity.Article' which is neither configured nor has any configured sub-types. ``` 这个问题无非就是说`com.aaa.entity.Article`不是索引的类，没加`@Indexed`？没加`@Indexed`确实会报这个错误，但是我的`Entity`是直接拷贝过来的，所以不存在是加没加注解的问题。 除此之外，就是加了注解，但是没有扫描到。 由于`Spring Boot`项目默认是只会扫描与`Application`类同级以及其子级包中的注解，所以再看看之前两个不同的包名： 1. com.aaa 2. com.bbb 新项目的`Application`类就是在`com.aaa`下，所以新建的`com.bbb.entity`包下面的类肯定是扫描不到的，所以解决版本就是让`Spring Boot`去扫描它即可： ``` @EnableJpaRepositories @EntityScan("com.bbb.entity") ``` 至此，问题解决。 最后还有个问题需要注意的就是，如果要是用`IKAnalyzer`分词器的话，需要`exclude`掉`Lucene`相关的包，让项目使用`hibernate-search-orm`里面依赖的版本。我的依赖如下： ``` 4.0.0 org.springframework.boot spring-boot-starter-parent 2.2.2.RELEASE com.bde4 v2 0.0.1-SNAPSHOT war v2 Demo project for Spring Boot 1.8 1.3.61 org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-web com.fasterxml.jackson.module jackson-module-kotlin org.jetbrains.kotlin kotlin-reflect org.jetbrains.kotlin kotlin-stdlib-jdk8 org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.1 mysql mysql-connector-java runtime org.springframework.boot spring-boot-starter-tomcat provided org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine org.springframework.boot spring-boot-devtools true com.github.pagehelper pagehelper-spring-boot-starter 1.2.13 com.alibaba druid-spring-boot-starter 1.1.21 com.rometools rome 1.12.2 org.apache.commons commons-lang3 3.9 commons-codec commons-codec 1.13 org.springframework.boot spring-boot-starter-data-jpa org.hibernate hibernate-search-orm 5.11.4.Final com.jianggujin IKAnalyzer-lucene 5.3.0 lucene-core org.apache.lucene lucene-analyzers-common org.apache.lucene lucene-queries org.apache.lucene lucene-queryparser org.apache.lucene central Maven Repository Switchboard default http://repo1.maven.org/maven2 false ${project.basedir}/src/main/kotlin ${project.basedir}/src/test/kotlin org.springframework.boot spring-boot-maven-plugin org.jetbrains.kotlin kotlin-maven-plugin -Xjsr305=strict spring org.jetbrains.kotlin kotlin-maven-allopen ${kotlin.version} kotlin-maven-plugin org.jetbrains.kotlin ${kotlin.version} no-arg no-arg:annotation=com.bde4.v2.annotation.NoArg org.jetbrains.kotlin kotlin-maven-noarg ${kotlin.version} ```
####前言 无论使用 JSP、还是Thymeleaf 或者其他模板语言，在页面中经常会用到一些公共的`css`和`js`文件，这里不说其他的模板语言如何实现，今天就说说`Thymeleaf`如何实现，以及网上那些文章有什么问题！ ####实现 由于对`thymeleaf`的语法不怎么熟悉，所以首先是搜索引擎一搜，结果搜到的是千篇一律的用法，大致就是如下： - 新建`common.html` ```html Hello world! ... ``` - 新建`demo.html` ```html 需要替换的title ``` 如果没有额外的`meta`、`link`或者`script`，用`~{}`代替。 好了，上面做法错事没错，刚开始用着发现没什么问题，当我在页面的非`head`部分写了其他的`script`，这种做法也很常见吧，比如`footer`块引入一些js文件。 但是查看网页源码发现，除了`head`里面的`script`标签被引入到了公共代码块里面，整个`html`其他地方的`script`也全被加到了`head`里面，这样一来就出现了重复引入。 如果仅仅是引入`js`文件，不查看网页源代码的话还发现不了这个问题，这个问题不仅引起`js`代码的重复引用，当你想用`document.write("xxxx")`输出一些信息到指定位置时，发现在页面顶部也打印出来了，这也是重复引入`script`代码导致的问题。 于是查看文档，`~{}`的用法： |语法|描述| |--|--| |~{templatename}|引用整个模板文件的代码片段| |~{templatename :: selector}|selector 可以是 th:fragment 指定的名称或其他选择器。如类选择器、ID选择器等| |~{::selector}|相当于 ~{this :: selector}，表示引用当前模板定义的代码片段| 首先想到的是能不能用`XPATH`语法，随便试了下，好像不行。 那么如何只是获取``中的``或者``标签元素呢？ 上面说了支持`类选择器、ID选择器等`，于是给``或者``加个`class`，确实可行。 但是感觉在``、``上加`class`总觉得有点奇怪。 想到`css`样式语法中`head script`即可定义`script`标签的样式，于是尝试模板改为`~{::head script}`，结果报错，无法解析。 一路波折，这问题国内搜索时无望了，全是千篇一律的前面的用法，我在想，难道上面那样写就没有遇到和我一样的问题？ 最终`Github`一个`issue`中获得答案。 正确写法如下： ``` ~{::head/script} ``` 解释：这跟指定`script`的作用域差不多，意思就是获取`head`标签里面所有的`script`代码块。如果只写`~{::script}`那么取的是整个页面所有的`script`代码块。 ####结语 国内文章真的太水了，一不小心又浪费了大把时间。
####前言 做个站真的不容易，本来访问量就没多少，还整天有些人想搞你，网站做了评论功能，本来是有加防`XSS`攻击的过滤器的，但不记得什么时候什么原因在`web.xml`中将过滤器去掉了，导致今天的悲剧发生。 ####分析 本次被攻击成功的结果就是，只要一打开带有XSS评论的页面，就直接跳到一个博彩的网站去了。第一反应就是先修改`jsp`中的评论代码，将评论区代码用``注释掉，注意不是用``注释，那样的话我们在页面上看不到`XSS`攻击的代码。 修改后于是再次打开我们之前的页面，既然定位到了是评论的`XSS`攻击，所以这次页面成功打开并未跳转了，当然，此时的评论模块是一片空白，我们需要右键选择查看源代码，然后找到我们注释掉的评论区，此时发现果然，评论内容里带有如下代码： ``` 评论内容window.location = "http://dd.ma/OONrEDXu"; ``` 很明显上面的代码只要被网页一加载就会跳转到第三方网站，大家也记住上面这个`dd.ma`，死妈的东西！ 于是数据库直接搜索`script`，找到该评论，然后删除。 接着把`jsp`注释取消，再次打开网站，麻麦皮的还是跳转！再次去数据库评论表搜索，发现以前某用户的一条评论变成了带有上面`XSS`攻击代码的评论，并且评论者ID变成了我的ID，难道这`XSS`攻击还有感染的能力，还能修改数据库？`XSS`攻击貌似不可能做到吧，于是再次数据库删除，再次打开网页，果然，网页还是跳转的！ 经过多次尝试，发现不直接删除带有攻击的评论纪录，而是修改评论内容去掉攻击代码，网页不会进行跳转。 有个更奇怪的现象就是，某次删除带有攻击评论的记录后网页还是跳转但是数据库中评论表中却搜不到攻击代码了，查看网页源码居然在分类栏目中某个分类带有了攻击代码。 之前被感染的同是评论表还没什么的，但删除评论表感染到了分类表，真是够奇怪的。 由于分类不能直接删除，直接进行修改将攻击代码删除，网页恢复正常，防止其他表被感染，彻底清除攻击代码，我将所有表都搜索了一遍，未发现攻击代码这才放心下来。 至于之前的感染问题，我倒觉得不像是`XSS`攻击人故意而为之的，倒像是数据库删除数据时导致的，具体为什么会这样我也不清楚。 ####处理方法 由于这次是自己关闭了防`XSS`过滤器的原因导致了攻击者的趁虚而入，所以我直接恢复过滤器问题就得以解决了，网站属于`SpringMVC`项目，大家可以自行搜索如何防止`XSS`攻击，最简单的就是对网页提交内容做正则匹配剔除掉`html`标签代码。 但`Google`发现有人说不建议用正则表达式去检查`XSS`攻击，原文如下： [Regex is not a tool that can be used to correctly parse HTML.][1] 由于我使用的是`SpringMVC`，所以直接用以下工具类来对`XSS`攻击做检测： ```java public class XssUtils { /** * Verify if a string contains any HTML characters by comparing its * HTML-escaped version with the original. * @param String input the input String * @return boolean True if the String contains HTML characters */ public static boolean isHtml(String input) { boolean isHtml = false; if (input != null) { if (!input.equals(HtmlUtils.htmlEscape(input))) { isHtml = true; } } return isHtml; } } ``` 原文链接： [There are much better ways to prevent XSS attacks.][2] 至此问题告一段落。 **第二天** 前面说的其他表被改的问题已经找到，今天发现攻击者可改任何表数据，查询数据库并无异常ip连接，并且被改数据都是通过网站接口所改，所以大致可以肯定是后台被爆破或者绕过了权限拦截。 目前解决办法是暂时先将所有请求进行`XSS`拦截过滤，攻击者发现修改数据无法达到他的目的暂时歇停下来了，但是如果真的是后台被迫，那么哪天被人清空了数据那就悲剧了，所以这几天每天进行备份，后台记录登录信息，必须弄清数据被改的原因。 [1]: https://stackoverflow.com/a/1732454/3626537 [2]: https://stackoverflow.com/a/5769859/3626537