1. 项目背景

项目地址: https://github.com/wangyuheng/Haro-CMS

最近因为换电脑整理资料的过程中,发现了自己之前写过的一些项目,现在准备拿出来升级一下,构建自己的技术体系。看着三个月前的代码,已经似曾相识了,那三年前的呢?
这个项目是用来做企业展示的简易CMS系统,后台管理员可以编辑展示内容,发布新闻;访问用户可以查看企业信息,并反馈建议。这个系统当时应该熬了几个通宵,因为5000大洋实在太吸引人。。而且还是三个人分。。。人的时间好像是越往后越值钱,现在看可能觉得不值,但还是很感谢熬夜编码的自己。
项目当时考虑了很多:分布式,前后端分离,甚至是saas化,希望根据用户反馈,或者再接到类似的项目,可以进一步完善,但是并没有什么后续。所以项目历史就是一个基于web.xml配置的spring单机应用。

1.1 依赖项目

  1. spring-boot
  2. h2
  3. gson

目前将其升级为spring-boot应用,并且为了开发演示方便,开发环境使用了H2数据库。一直觉得,项目不需要修改任何配置就能跑起来很重要。

2. 项目code

企业信息展示,分为3个module

  1. admin 管理人员编辑展示内容
  2. view 展示企业信息及新闻
  3. core 业务数据层

haro_designpng

新版spring boot已不推荐使用jsp,带来很多不便,如: 不能直接运行java, 需要使用maven spring boot 插件运行。

mvn spring-boot:run

admin 和 view 只负责业务渲染与鉴权,业务操作放在core中,方便后期进行前后端分离。
生产环境数据库使用mysql,为了方便演示,开发环境使用H2内嵌数据库。

2.1 admin

haro_adminpng

2.2 view

haro_viewpng

3 问题

之前的项目基于spring开发,采用web.xml配置。spring-boot通过约定大于配置,极大的简化了这部分工作,但是有时候又会因为不熟悉带来迷茫。明明我没有这个配置,怎么就生效了。。而且jsp又不推荐使用,所以虽然大部分是在删代码,单还是遇到了很多问题。

3.1 设置视图解析器InternalResourceViewResolver

原项目在mvc-dispatcher-servlet.xml文件中配置

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/WEB-INF/pages/"/>
        <property name="suffix" value=".jsp"/>
    </bean>

spring-boot提供了默认配置prefix = “spring.mvc”,详情可以参看org.springframework.boot.autoconfigure.web.servlet.WebMvcProperties类, 并在org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration类中自动注入。

@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class,
        ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {
...
        @Bean
        @ConditionalOnMissingBean
        public InternalResourceViewResolver defaultViewResolver() {
            InternalResourceViewResolver resolver = new InternalResourceViewResolver();
            resolver.setPrefix(this.mvcProperties.getView().getPrefix());
            resolver.setSuffix(this.mvcProperties.getView().getSuffix());
            return resolver;
        }
...
}

所以只需在application.properties中添加配置项

spring.mvc.view.prefix=/WEB-INF/pages/
spring.mvc.view.suffix=.jsp

3.2 静态资源请求

未实现前后端分离的项目,css、js等静态资源仍保存在项目中。spring-boot建议保存在resource/static目录下,并通过spring.resources.static-locations属性设置目录位置,并且需要制定mapping规则。

spring.mvc.static-path-pattern=/static/**
spring.resources.static-locations=classpath:/static/

3.3 spring-boot 运行war项目

需要添加tomcat-embed依赖,并且设置servlet初始化。如果使用jsp标签,一般也需要添加jstl依赖。

    <dependencies>
        <dependency>
            <groupId>jstl</groupId>
            <artifactId>jstl</artifactId>
            <version>1.2</version>
        </dependency>
        <!-- Provided -->
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
@SpringBootApplication
@ComponentScan(basePackages={"wang.crick.business.haro"})
public class ViewAPP extends SpringBootServletInitializer {

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(ViewAPP.class);
    }

    public static void main(String[] args) {
        SpringApplication.run(ViewAPP.class, args);
    }

}

需要通过spring-boot maven插件运行。

mvn spring-boot:run

3.4 自定义请求后缀匹配

旧项目中为了提高seo通过servlet-mapping指定请求路径为.html

    <servlet>
        <servlet-name>mvc-dispatcher</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>mvc-dispatcher</servlet-name>
        <url-pattern>*.html</url-pattern>
    </servlet-mapping>

spring-boot在org.springframework.boot.autoconfigure.web.ServerProperties提供了servlet相关配置,可以通过application.properties配置

server.servlet.path=*.html

但是目前希望忽略后缀,即请求index和index.html都可以路由到对应的页面,此时可以通过设置pathmatch属性实现

   /**
     * Whether to use suffix pattern match (".*") when matching patterns to requests.
     * If enabled a method mapped to "/users" also matches to "/users.*".
     */
spring.mvc.pathmatch.use-suffix-pattern=true

3.5 使用内存数据库H2

为了让程序可以“无痛”运行,在代码中使用了基于内存的H2数据库,生产环境可以考虑mysql等关系型数据库,只需修改对应的配置即可。

需要添加H2依赖,引入驱动。

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
        </dependency>

在配置文件application.properties中直接配置路径、驱动、以及账户信息

spring.datasource.url=jdbc:h2:mem:~/.h2/haro
spring.datasource.schema=classpath:db/schema.sql
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=root
spring.datasource.password=123456

db/schema.sql放置在resource目录下,保存了DDL以及开发环境所需数据。DDL支持情况为标准sql语句,部分工具导出的脚本需要简单修改。在程序启动过程中,会初始化执行,如果有异常,会在控制台中有详细提示。

1 简介

阅读本文需要你已掌握如下知识

  1. git
  2. markdown

现在写blog的人越来越少,但是在程序员这个群体中,还是很愿意分享自己的经验,也是对自己知识的一个积累。但是通过网站记录博客,定制化功能有限,不够灵活,也不希望给自己带来流量。而自己搭建一个博客平台的成本又比较大,这时可以考虑使用静态博客框架,同时兼顾了便捷性与功能性。hexo是其中的佼佼者,官方的定义如下

A fast, simple & powerful blog framework

hexo是一款静态博客生成框架,拥有多套主题,可以通过github发布自己的博客,满足了程序员的geek特质。

Read more »

1. 项目背景

朋友在一家日企,运维工具老旧,希望增加一款系统告警工具。我提议通过微信企业号(改版为企业微信)或者短信推送告警信息。提出两点要求:

  1. 可以群发给企业微信中的多个人
  2. 监测健康检测服务是否存活(绕嘴。。)

做了第一版demo,定位为通信渠道的http代理。贴出企业微信相关代码,如果有需要的同学可以拿去用,记得点个star就好。
https://github.com/wangyuheng/pharos

1.1 依赖项目

因为定位为http代理,并未使用数据库及持久化工具。

  1. springboot
  2. 企业微信接口 https://work.weixin.qq.com/api/doc
  3. 可能是目前最好最全的微信Java开发工具包(SDK)https://github.com/Wechat-Group/weixin-java-tools
  4. swagger 工具 https://github.com/wangyuheng/spring-boot-swagger-starter

2. 项目code

代理入口为健康检测工具,出口为企业微信、短信等。同时需要保障和健康检测工具之间的网络通畅。

wechat-message-proxy-00

最近在尝试画图

2.1 微信接口

企业微信相关配置如下

wechat:
  cp:
    corpid:
    agentid:
    corp:
      secret:
  1. corpid 企业ID
  2. agentid 应用ID,在企业微信管理后台创建应用后,可以查看应用ID
  3. corp.secret 应用的凭证密钥

weixin-java-tools已经对微信接口进行了友好的封装,可以通过标签、部门等分组,查询用户标识。配置bean方法如下

package com.crick.business.pharos.config;

import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.api.impl.WxCpDepartmentServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpTagServiceImpl;
import me.chanjar.weixin.cp.api.impl.WxCpUserServiceImpl;
import me.chanjar.weixin.cp.config.WxCpConfigStorage;
import me.chanjar.weixin.cp.config.WxCpInMemoryConfigStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass(WxCpService.class)
public class WechatCpConfig {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Value("${wechat.cp.corpid}")
    private String corpid;
    @Value("${wechat.cp.corp.secret}")
    private String corpSecret;
    @Value("${wechat.cp.agentid}")
    private Integer agentid;

    @Bean
    @ConditionalOnMissingBean
    public WxCpConfigStorage configStorage() {
        WxCpInMemoryConfigStorage configStorage = new WxCpInMemoryConfigStorage();
        logger.info("****************wechat properties start****************");
        logger.info("corpid:{}", corpid);
        logger.info("corpSecret:{}", corpSecret);
        logger.info("agentid:{}", agentid);
        logger.info("****************wechat properties end****************");
        configStorage.setCorpId(corpid);
        configStorage.setCorpSecret(corpSecret);
        configStorage.setAgentId(agentid);
        return configStorage;
    }

    @Bean
    @ConditionalOnMissingBean
    public WxCpService WxCpService(WxCpConfigStorage configStorage) {
        WxCpService wxCpService = new WxCpServiceImpl();
        wxCpService.setWxCpConfigStorage(configStorage);
        wxCpService.setTagService(new WxCpTagServiceImpl(wxCpService));
        wxCpService.setDepartmentService(new WxCpDepartmentServiceImpl(wxCpService));
        wxCpService.setUserService(new WxCpUserServiceImpl(wxCpService));
        return wxCpService;
    }

}

同时也提供了发送消息、已经封装消息的类

package com.crick.business.pharos.service;

import me.chanjar.weixin.cp.bean.WxCpMessage;

public class AlertTextBuilder {

    private Integer agentid;

    public AlertTextBuilder(Integer agentid) {
        this.agentid = agentid;
    }

    public WxCpMessage buildForTag(String content, String tag) {
        return WxCpMessage.TEXT().agentId(agentid).content(content).toTag(tag).build();
    }

    public WxCpMessage buildForUsers(String content, String users) {
        return WxCpMessage.TEXT().agentId(agentid).content(content).toUser(users).build();
    }

}
package com.crick.business.pharos.service;

import me.chanjar.weixin.common.exception.WxErrorException;
import me.chanjar.weixin.cp.api.WxCpService;
import me.chanjar.weixin.cp.bean.WxCpUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class WechatAlertService implements AlertService {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    private WxCpService wxCpService;
    @Autowired
    private AlertTextBuilder alertTextBuilder;

    @Override
    public void alertTextToTag(String content, String tag) {
        try {
            wxCpService.messageSend(alertTextBuilder.buildForTag(content, tag));
        } catch (WxErrorException e) {
            logger.error("alertTextToTag error! tag:{}", tag, e);
        }
    }

    @Override
    public void alertTextToUsers(String content, List<String> users) {
        try {
            wxCpService.messageSend(alertTextBuilder.buildForUsers(content, String.join(",", users)));
        } catch (WxErrorException e) {
            logger.error("alertTextToUsers error! users:{}", users, e);
        }
    }

    @Override
    public void alertTextToDepartment(String content, Integer department) {
        try {
            List<WxCpUser> wxCpUserList = wxCpService.getUserService().listSimpleByDepartment(department, true, 0);
            if (null != wxCpUserList) {
                String userList = wxCpUserList.stream()
                        .map(WxCpUser::getUserId)
                        .collect(Collectors.joining(","));
                wxCpService.messageSend(alertTextBuilder.buildForUsers(content, userList));
            }
        } catch (WxErrorException e) {
            logger.error("alertTextToDepartment error! department:{}", department, e);
        }
    }

}

2.2 restful接口

对外暴露的接口主要提供两个功能

  1. 代理企业微信,用于查询部门、标签、userId等
  2. 发送告警信息

具体实现在service中,restful暴露了http请求接口及swagger接口。并且将首页指向了swagger页面


@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "redirect:swagger-ui.html";
    }
}

wechat-message-proxy-01

2.3 验权

两种验权方式

  1. 参数+secret通过SHA加密签名
  2. ip白名单

通过interceptor实现

2.3.1 白名单校验

public class AuthorInterceptor extends HandlerInterceptorAdapter {

    @Value("#{'${white.list}'.split(',')}")
    private List<String> whiteList;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) {
                return true;
            }
        }
        String clientIp = getIpAddress(request);
        if (!whiteList.contains(clientIp)) {
            throw new RestfulException("client ip: " + clientIp + " not in white list", RestfulErrorCode.AUTHOR_ERROR);
        }
        return super.preHandle(request, response, handler);
    }

    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("x-forwarded-for");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

2.3.2 接口参数签名校验

public class SignInterceptor extends HandlerInterceptorAdapter {

    @Value("${secret.key}")
    public String secretKey;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getBeanType().isAnnotationPresent(Anonymous.class)) {
                return true;
            }
        }

        String sign = request.getParameter("sign");
        if (null == sign || "".equals(sign)) {
            throw new RestfulException("must have a sign param!", RestfulErrorCode.SIGN_ERROR);
        } else {
            Map<String, String[]> parameters = request.getParameterMap();
            if (parameters.size() > 0) {
                StringBuilder sb = new StringBuilder();
                for (String key : parameters.keySet()) {
                    if ("sign".equals(key)) {
                        continue;
                    }
                    sb.append(key).append("-").append(Arrays.toString(parameters.get(key))).append("-");
                }
                sb.append("token").append("-").append(secretKey);
                if (!sign.equals(EncryptUtil.sha1(sb.toString()))) {
                    throw new RestfulException("sign check fail!", RestfulErrorCode.SIGN_ERROR);
                }
            }
        }
        return super.preHandle(request, response, handler);
    }

}

SHA加密工具封装

public class EncryptUtil {

    private EncryptUtil() {
    }

    private static final String SHA_1_ALGORITHM = "SHA-1";
    private static final String SHA_256_ALGORITHM = "SHA-256";

    public static String sha1(String source) {
        return sha(source, SHA_1_ALGORITHM);
    }

    public static String sha256(String source) {
        return sha(source, SHA_256_ALGORITHM);
    }

    private static String sha(String source, String instance) {
        MessageDigest md;
        try {
            md = MessageDigest.getInstance(instance);
            md.update(source.getBytes());
            return new String(Hex.encodeHex(md.digest()));
        } catch (NoSuchAlgorithmException e) {
            return null;
        }
    }
}

如果需要对sign有效期进行校验,需要提供获取服务器时钟的方法,避免因为服务器时间不一致导致的时间差, 此方法可以通过@Anonymous去掉验权操作。

@RestController
@RequestMapping("common")
@Anonymous
public class CommonController {

    /**
     * 获取系统时间,避免客户端时间不一致
     */
    @GetMapping("current")
    public Long current() {
        return System.currentTimeMillis();
    }
}

2.4 健康检测

定时用http get 请求确认网络通畅,如果网络连接失败次数超过阈值,报警给系统管理员

通过Scheduled编写定时任务

@Component
public class PingCheckTask {
    private static OkHttpClient okHttpClient = new OkHttpClient();

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Value("${ping.service.url}")
    private String serviceUrl;
    @Value("${ping.period:3}")
    private int period;

    private static Map<String, Integer> errorCalculate = new ConcurrentHashMap<>();

    private void resetCount(String url) {
        errorCalculate.put(url, 0);
    }

    private int pushCount(String url) {
        errorCalculate.put(url, errorCalculate.getOrDefault(url, 0) + 1);
        return errorCalculate.get(url);
    }

    @Scheduled(cron = "0/20 * * * * ?") // 每20秒执行一次
    public void scheduler() throws IOException {
        Request request = new Request.Builder()
                .url(serviceUrl)
                .build();
        Response response = okHttpClient.newCall(request).execute();
        if (response.isSuccessful()) {
            logger.info("ping check {} success!", serviceUrl);
            resetCount(serviceUrl);
        } else {
            logger.info("ping check {} fail! response:{}", serviceUrl, response);
            int count = pushCount(serviceUrl);
            if (count > period) {
                // alert to admin!
            }
        }
    }
}

在配置bean中需要注入bean并允许启动调度

@Configuration
@EnableScheduling
public class WebConfig implements WebMvcConfigurer {

 @Bean
    public PingCheckTask pingCheckTask(){
        return new PingCheckTask();
    }
}

3. 其他

项目写的比较仓促,后续根据实际使用场景进行调整优化。都是站在巨人的肩膀上,利用现成的工具进行拼装。 如果有建议或者希望实现哪些功能,可以留言或者给我提issue https://github.com/wangyuheng/pharos

春城无处不飞花,寒食东风御柳斜。

推荐大家吃 青团(艾团), 简直发现了新大陆

wechat-message-proxy-02

1. 关于阅读源码

工作中使用到的框架容易成为最熟悉的陌生代码,可以熟练使用,但是并不知道其中原理。阅读源码可以帮助我们知其然,亦知其所以然,既可以在工作中更好的使用框架特性,也可以借鉴其中优秀的设计思想。

但是面对框架庞大的代码量,往往不知如何下手。总结了一些方法,可以帮助coder战胜恐惧,顺利的开始阅读源码。

Read more »

keyword

2017-0

2017于我而言,是变化的一年。从东北到江南,从在线教育到互联网金融,工作、生活都经历了很多变动,在这个过程中,努力拥抱变化,提升自己。有提升,也有不足。总结一下这一年的种种:

Read more »

本文项目已发布到github,后续学习项目也会添加到此工程下,欢迎fork点赞。
https://github.com/wangyuheng/spring-boot-sample

http接口开发过程中的常用场景为,根据提交的表单数据进行格式校验,包括字段长度、数据类型、范围等等。。如果每次都写一堆if…else if… 太傻了,所以java提供了一套标准化校验方案JSR 303,而标准的最佳实践为Hibernate Validator

一句话为,通过注解对bean进行校验,并返回标准文案。

Read more »

全局异常处理

本文项目已发布到github,后续学习项目也会添加到此工程下,欢迎fork点赞。
https://github.com/wangyuheng/spring-boot-sample

偷懒代码

偷懒是程序员的美德,但是有些偷懒是为了少写代码,有些则是少思考,直接copy。

不知道你有没有见过这种代码,在最外层catch Exception,避免抛出异常信息。。。

    @RestController
    @RequestMapping("/user")
    public class UserApi {

        @Autowired
        private UserService userService;

        @GetMapping("/{id}")
        public Object getInfo(@PathVariable("id") int id) {
            try {
                return userService.getUsernameById(id);
            } catch (UserException) {
                return "用户id异常";
            } catch (Exception e) {
                return "服务异常,请稍后再试!";
            }
        }

    }

UserService用于模拟异常抛出

    @Service
    public class UserService {

        public String getUsernameById(long id) {
            if (0 == id % 2) {
                throw new IllegalArgumentException("error param!");
            } else {
                throw new UserException("custom exception!");
            }
        }
    }

全局异常处理

自定义异常

通过自定义异常,区分业务异常,并增加errorCode支持,返回给接口调用方。

    public enum ErrorCode {
        Error(10000, "服务异常"),
        UserIdError(10001, "用户id异常");

        private int code;
        private String message;

        ErrorCode(int code, String message) {
            this.code = code;
            this.message = message;
        }

        public int getCode() {
            return code;
        }

        public String getMessage() {
            return message;
        }
    }
    public class CustomException extends RuntimeException {

        private int errorCode;

        public CustomException(int errorCode, String message) {
            super(message);
            this.errorCode = errorCode;
        }

        public int getErrorCode() {
            return errorCode;
        }
    }
    public class UserException extends CustomException {
        public UserException(String message) {
            super(ErrorCode.UserIdError.getCode(), message);
        }

        public UserException() {
            super(ErrorCode.UserIdError.getCode(), ErrorCode.UserIdError.getMessage());
        }
    }

@ControllerAdvice

会应用到所有的Controller中的@RequestMapping注解的方法中,通过annotations = RestController.class指定代理的Controller类中。

@ExceptionHandler

用于捕获异常,@ExceptionHandler本身只能捕获当前类的异常信息,结合@ControllerAdvice可以捕获全部controller异常。

    @ControllerAdvice(annotations = RestController.class)
    @ResponseBody
    public class GlobalExceptionHandler {

        private Map<String, Object> getErrorObject(int code, String message) {
            Map<String, Object> error = new HashMap<>();
            error.put("code", code);
            error.put("message", message);
            return error;
        }

        @ExceptionHandler(Exception.class)
        public Object exceptionHandler() {
            return getErrorObject(ErrorCode.Error.getCode(), ErrorCode.Error.getMessage());
        }

        @ExceptionHandler(CustomException.class)
        public Object customException(CustomException e) {
            return getErrorObject(e.getErrorCode(), e.getMessage());
        }

    }

优化代码

优化后的代码简洁了,无需每次try…catch,统一管理异常信息。

    @RestController
    @RequestMapping("/user")
    public class UserApi {

        @Autowired
        private UserService userService;

        @GetMapping("/{id}")
        public Object getInfo(@PathVariable("id") int id) {
            return userService.getUsernameById(id);
        }

    }

测试

mockMvc请求api接口,判断返回值

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class UserApiTest {

        private MockMvc mockMvc;

        @Autowired
        private WebApplicationContext context;

        @Autowired
        private UserApi userApi;

        @Before
        public void setup() {
            mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
        }

        private JSONObject getUserApiResult(int id) throws Exception {
            String path = "/user/" + id;
            return new JSONObject(mockMvc.perform(MockMvcRequestBuilders.get(path))
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andDo(MockMvcResultHandlers.print())
                    .andReturn()
                    .getResponse()
                    .getContentAsString());
        }

        @Test
        public void test_exception_handler() throws Exception {
            int i = 1;
            assertTrue(ErrorCode.UserIdError.getCode() == getUserApiResult(i).getInt("code"));
            i++;
            assertTrue(ErrorCode.Error.getCode() == getUserApiResult(i).getInt("code"));
        }

    }

本文项目已发布到github,后续学习项目也会添加到此工程下,欢迎fork点赞。
https://github.com/wangyuheng/spring-boot-sample

你的java应用在运行时对你来说是黑盒吗?你可以查看到springboot运行时的各种信息吗?

Spring Boot Actuator

springboot提供了用于健康检测的endpoint,提供了查看系统信息、内存、环境等。

1. 添加依赖

   <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-actuator</artifactId>
   </dependency>

2. 访问链接

应用启动后访问 http://localhost:8080/health 查看应用启动状态。
端口号可以通过配置文件management.port=9527变更

常用endpoint

  1. http://localhost:8080/env 环境变量
  2. http://localhost:8080/info 应用信息
  3. http://localhost:8080/metrics 内存等应用基本指标
  4. http://localhost:8080/dump 线程栈
  5. http://localhost:8080/configprops 配置项

3. 开启全部endpoint & 关闭验权

部分请求会返回401, 这是因为endpoint未开启,或者开启了登录验权,可以通过配置文件进行配置

management.security.enabled=false
endpoints.enabled=true

自定义endpoint

一、实现Endpoint接口

    public interface Endpoint<T> {
        String getId();

        boolean isEnabled();

        boolean isSensitive();

        T invoke();
    }
  1. getId(), 指定了endpoint访问url
  2. isEnabled(), 表示是否启用
  3. isSensitive(), 表示是否验权
  4. invoke(), 页面返回值

实现类通过@Bean的形式注入后,再次启动应用,即可通过url访问,并返回invoke返回值。

    public class CustomEndpoint implements Endpoint {

        @Override
        public String getId() {
            return "custom";
        }

        @Override
        public boolean isEnabled() {
            return true;
        }

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

        @Override
        public Object invoke() {
            return "hello endpoint";
        }
    }
    @SpringBootApplication
    public class EndpointApplication {

        public static void main(String[] args) {
            SpringApplication.run(EndpointApplication.class, args);
        }


        @Bean
        public CustomEndpoint customEndpoint() {
            return new CustomEndpoint();
        }
    }

访问http://localhost:8080/custom 可以看到invoke返回的内容。

但是这样,每个endpoint都需要单独注入,且没有层级、通配符,不方便管理,为满足需求,尝试做了如下改造

二、继承EndpointMvcAdapter

2.1 自定义 EndpointAction 接口

用于定制endpoint处理行为,可以理解为invoke方法的具体实施者,名称用来管理访问路径

    public interface EndpointAction extends Serializable {
        Object execute();

        String getName();
    }
2.2 EndpointAction的多种实现

需要实现的功能,如:读取配置文件、查看内存信息

    @Component
    public class PropertiesAction implements EndpointAction {

        @Override
        public Object execute() {
            try {
                return PropertiesLoaderUtils.loadAllProperties("application.properties");
            } catch (IOException e) {
                return "read application fail! error: " + e.getMessage();
            }
        }

        @Override
        public String getName() {
            return "properties";
        }
    }
    @Component
    public class VMAction implements EndpointAction {

        private static final VMAction INSTANCE = new VMAction();

        private String version;
        private String startTime;
        private String initHeap;
        private String maxHeap;
        private Set<String> arguments;

        @Override
        public Object execute() {
            INSTANCE.version = System.getProperty("java.runtime.version");
            INSTANCE.startTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ManagementFactory.getRuntimeMXBean().getStartTime());
            INSTANCE.initHeap = String.valueOf(ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getInit() / 1024 / 1024).concat("MB");
            INSTANCE.maxHeap = String.valueOf(ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getMax() / 1024 / 1024).concat("MB");
            INSTANCE.arguments = new HashSet<>(ManagementFactory.getRuntimeMXBean().getInputArguments());
            return INSTANCE;
        }

        @Override
        public String getName() {
            return "vm";
        }

        public String getVersion() {
            return version;
        }

        public String getStartTime() {
            return startTime;
        }

        public String getInitHeap() {
            return initHeap;
        }

        public String getMaxHeap() {
            return maxHeap;
        }

        public Set<String> getArguments() {
            return arguments;
        }
    }
    @Component
    public class DefaultAction implements EndpointAction {


        private static final DefaultAction INSTANCE = new DefaultAction();

        public static DefaultAction getInstance() {
            return INSTANCE;
        }

        @Override
        public Object execute() {
            return "try /help for action list";
        }

        @Override
        public String getName() {
            return "default";
        }

    }
2.3 继承EndpointMvcAdapter
  1. 注入action map,根据name获取bean实现
  2. 通过url mapping匹配action
    public class CustomEndpointAdapter extends EndpointMvcAdapter {


        private Map<String, EndpointAction> endpointActionMap = new HashMap<>();

        @Autowired
        public void setEndpointActionMap(List<EndpointAction> endpointActionList) {
            endpointActionList.forEach(endpointAction -> endpointActionMap.put(endpointAction.getName(), endpointAction));
        }

        public CustomEndpointAdapter() {
            super(new CustomEndpoint());
        }

        @RequestMapping(value = "/{name:.*}",
                method = RequestMethod.GET, produces = {
                ActuatorMediaTypes.APPLICATION_ACTUATOR_V1_JSON_VALUE,
                MediaType.APPLICATION_JSON_VALUE}
        )
        @ResponseBody
        @HypermediaDisabled
        public Object dispatcher(@PathVariable String name) {
            if ("help".equalsIgnoreCase(name)) {
                return endpointActionMap.keySet().stream().map(key -> getName() + "/" + key).collect(toSet());
            } else {
                return endpointActionMap.getOrDefault(name, DefaultAction.getInstance()).execute();
            }
        }

    }
2.4 定制endpoint作为入口
    public class CustomEndpoint implements Endpoint {

        @Override
        public String getId() {
            return "custom";
        }

        @Override
        public boolean isEnabled() {
            return true;
        }

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

        @Override
        public Object invoke() {
            return DefaultAction.getInstance().execute();
        }
    }
2.5 启动时注入
    @SpringBootApplication
    public class EndpointApplication {

        public static void main(String[] args) {
            SpringApplication.run(EndpointApplication.class, args);
        }


        @Bean
        @ConditionalOnClass(CustomEndpoint.class)
        public CustomEndpointAdapter customEndpointAdapter() {
            return new CustomEndpointAdapter();
        }
    }

测试

揪心的测试环节,启动webEnvironment环境,通过TestRestTemplate访问endpoint的mapping,比对返回值即可

    @RunWith(SpringRunner.class)
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    public class PropertiesActionTest {

        private TestRestTemplate restTemplate;

        @Value("${management.port}")
        private String managementPort;

        @Before
        public void setupMockMvc() {
            restTemplate = new TestRestTemplate();
        }

        @Test
        public void test_properties_action() throws Exception {
            String path = "http://localhost:" + managementPort + "/custom/properties";
            Map<String, String> result = restTemplate.getForObject(path, HashMap.class);
            assertEquals(result.get("management.port"), managementPort);
        }

        @Test
        public void test_help() throws Exception {
            String path = "http://localhost:" + managementPort + "/custom/help";
            Set<String> result = restTemplate.getForObject(path, Set.class);
            assertTrue(result.contains("custom/properties"));
        }

        @Test
        public void test_rand() throws Exception {
            String path = "http://localhost:" + managementPort + "/custom/" + new Random().nextInt();
            String result = restTemplate.getForObject(path, String.class);
            assertEquals("try /help for action list", result);

        }

    }

本文项目已发布到github,后续学习项目也会添加到此工程下,欢迎fork点赞。
https://github.com/wangyuheng/spring-boot-sample

需要具备少量aop基础,通过springboot构建项目方便演示。

AOP-面向切面编程

一句话描述,在java对象增加切点,在不改变对象的前提下通过代理扩展功能。

http日志打印拦截器

restful api

通过springboot快速搭建一个RestController接口。

    import org.springframework.web.bind.annotation.*;
    import wang.crick.study.httplog.annotation.HttpLog;
    import wang.crick.study.httplog.domain.User;

    import java.util.Random;

    @RestController
    @RequestMapping("/user")
    public class UserApi {

        @GetMapping("/log/{id}")
        public RestApiResponse<User> getInfo(@PathVariable("id") int id,
                                             @RequestParam("age") int age){
            User user = new User();
            user.setId(id);
            user.setUsername(String.valueOf(new Random().nextLong()));
            user.setAge(age);
            return RestApiResponse.success(user);
        }

        @GetMapping("/log/pwd/{id}")
        public RestApiResponse<User> getInfoWithPwd(@PathVariable("id") int id,
                                                    @RequestHeader("username") String username,
                                                    @RequestHeader("password") String password){
            User user = new User();
            user.setId(id);
            user.setUsername(username);
            user.setPassword(password);
            return RestApiResponse.success(user);
        }

        @GetMapping("/log/pwdExcludeResponse/{id}")
        public RestApiResponse<User> getInfoWithPwd(@PathVariable("id") int id,
                                                    @RequestParam("age") int age,
                                                    @RequestHeader("password") String password){
            User user = new User();
            user.setId(id);
            user.setPassword(password);
            user.setAge(age);
            return RestApiResponse.success(user);
        }

    }

切面选择

一般教程会选择拦截所有http请求,并打印request.parameters。但是存在问题:

  1. 不够灵活,部分参数不想打印,如文件数据(过大)、敏感数据(身份证)等。
  2. 显式的标注日志输出,避免给维护人员造成疑惑。
  3. 部分参数通过header传输

因此,自定义日志输出@annotation HttpLog

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface HttpLog {
        /**
         * 忽略参数,避免文件or无意义参数打印
         *
         * @return 忽略参数数组
         */
        String[] exclude() default {};

        /**
         * 需要打印的header参数
         *
         * @return header参数名数组
         */
        String[] headerParams() default {};

        boolean ignoreResponse() default false;
    }

获取HttpServletRequest

spring通过ThreadLocal持有request参数。

    private HttpServletRequest getRequest() {
       RequestAttributes ra = RequestContextHolder.getRequestAttributes();
       ServletRequestAttributes sra = (ServletRequestAttributes) ra;
       return sra.getRequest();
    }

获取uri

根据拦截规则不同,getServletPath()和request.getPathInfo()可能为空,简单的做一次健壮性判断。

    private String getRequestPath(HttpServletRequest request) {
       return (null != request.getServletPath() && request.getServletPath().length() > 0)
               ? request.getServletPath() : request.getPathInfo();

    }

aop日志输出

  1. Pointcut自定义@annotation HttpLog
  2. 拿到@annotation,读取自定义属性,如忽略response、打印headers等
  3. 遍历request & headers中需打印参数
  4. 定制日志格式并打印log
  5. 拦截返回值并打印log

Aspect代码如下

    import org.aspectj.lang.JoinPoint;
    import org.aspectj.lang.Signature;
    import org.aspectj.lang.annotation.AfterReturning;
    import org.aspectj.lang.annotation.Aspect;
    import org.aspectj.lang.annotation.Before;
    import org.aspectj.lang.annotation.Pointcut;
    import org.aspectj.lang.reflect.MethodSignature;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint;
    import org.springframework.web.context.request.RequestAttributes;
    import org.springframework.web.context.request.RequestContextHolder;
    import org.springframework.web.context.request.ServletRequestAttributes;
    import wang.crick.study.httplog.annotation.HttpLog;
    import wang.crick.study.httplog.api.RestApiResponse;

    import javax.servlet.http.HttpServletRequest;
    import java.lang.reflect.Method;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    import java.util.Optional;

    @Aspect
    public class HttpLogAspect {

        private Logger log = LoggerFactory.getLogger(HttpLogAspect.class);

        @Pointcut("@annotation(wang.crick.study.httplog.annotation.HttpLog)")
        public void logAnnotation() {
        }

        private Optional<HttpLog> getLogAnnotation(JoinPoint joinPoint) {
            if (joinPoint instanceof MethodInvocationProceedingJoinPoint) {
                Signature signature = joinPoint.getSignature();
                if (signature instanceof MethodSignature) {
                    MethodSignature methodSignature = (MethodSignature) signature;
                    Method method = methodSignature.getMethod();
                    if (method.isAnnotationPresent(HttpLog.class)) {
                        return Optional.of(method.getAnnotation(HttpLog.class));
                    }
                }
            }
            return Optional.empty();
        }

        private HttpServletRequest getRequest() {
            RequestAttributes ra = RequestContextHolder.getRequestAttributes();
            ServletRequestAttributes sra = (ServletRequestAttributes) ra;
            return sra.getRequest();
        }

        private String getRequestPath(HttpServletRequest request) {
            return (null != request.getServletPath() && request.getServletPath().length() > 0)
                    ? request.getServletPath() : request.getPathInfo();

        }

        @Before("logAnnotation()")
        public void requestLog(JoinPoint joinPoint) {
            try {
                Optional<HttpLog> httpLog = getLogAnnotation(joinPoint);
                httpLog.ifPresent(anno -> {
                    HttpServletRequest request = getRequest();
                    List<String> excludes = Arrays.asList(anno.exclude());
                    List<Object> params = new ArrayList<>();
                    StringBuilder logMsg = new StringBuilder();
                    logMsg.append("REQUEST_LOG. sessionId:{}. ")
                            .append("requestUrl: ")
                            .append(getRequestPath(request))
                            .append(" -PARAMS- ");
                    params.add(request.getSession().getId());
                    request.getParameterMap().forEach((k, v) -> {
                        if (!excludes.contains(k)) {
                            logMsg.append(k).append(": {}, ");
                            params.add(v);
                        }
                    });
                    if (anno.headerParams().length > 0) {
                        logMsg.append(" -HEADER_PARAMS- ");
                        Arrays.asList(anno.headerParams()).forEach(param -> {
                            logMsg.append(param).append(": {}, ");
                            params.add(request.getHeader(param));
                        });
                    }
                    log.info(logMsg.toString(), params.toArray());
                });
            } catch (Exception ignore) {
                log.warn("print request log fail!", ignore);
            }
        }


        @AfterReturning(returning = "restApiResponse", pointcut = "logAnnotation()")
        public void response(JoinPoint joinPoint, RestApiResponse restApiResponse) {
            try {
                Optional<HttpLog> httpLog = getLogAnnotation(joinPoint);
                httpLog.ifPresent(anno -> {
                    if (!anno.ignoreResponse()) {
                        log.info("RESPONSE_LOG. sessionId:{}. result:{}", getRequest().getSession().getId(), restApiResponse);
                    }
                });
            } catch (Exception ignore) {
                log.warn("print response log fail!", ignore);
            }
        }

    }

使用

在RestController中增加自定义注解HttpLog

    import org.springframework.web.bind.annotation.*;
    import wang.crick.study.httplog.annotation.HttpLog;
    import wang.crick.study.httplog.domain.User;

    import java.util.Random;

    @RestController
    @RequestMapping("/user")
    public class UserApi {


        /**
         * curl -H 'username:12b4' -H 'password:34ndd' -v 'http://localhost:8080/user/log/123?age=32'
         */
        @GetMapping("/log/{id}")
        @HttpLog()
        public RestApiResponse<User> getInfo(@PathVariable("id") int id,
                                             @RequestParam("age") int age){
            User user = new User();
            user.setId(id);
            user.setUsername(String.valueOf(new Random().nextLong()));
            user.setAge(age);
            return RestApiResponse.success(user);
        }

        /**
         * curl -H 'username:12b4' -H 'password:34ndd' -v 'http://localhost:8080/user/log/pwd/123?age=32'
         */
        @GetMapping("/log/pwd/{id}")
        @HttpLog(headerParams="password")
        public RestApiResponse<User> getInfoWithPwd(@PathVariable("id") int id,
                                                    @RequestHeader("username") String username,
                                                    @RequestHeader("password") String password){
            User user = new User();
            user.setId(id);
            user.setUsername(username);
            user.setPassword(password);
            return RestApiResponse.success(user);
        }

        /**
         * curl -H 'username:12b4' -H 'password:34ndd' -v 'http://localhost:8080/user/log/pwdExcludeResponse/123?age=32'
         */
        @GetMapping("/log/pwdExcludeResponse/{id}")
        @HttpLog(headerParams="username", ignoreResponse = true)
        public RestApiResponse<User> getInfoWithPwd(@PathVariable("id") int id,
                                                    @RequestParam("age") int age,
                                                    @RequestHeader("password") String password){
            User user = new User();
            user.setId(id);
            user.setPassword(password);
            user.setAge(age);
            return RestApiResponse.success(user);
        }

    }

加载HttpLogAspect对象

可以在@SpringBootApplication类下直接加在,也可以在HttpLogAspect中增加@Component注解,推荐前者,更清晰。不需要增加@EnableAspectJAutoProxy类 (注:1)

    @Bean
    public HttpLogAspect httpLogAspect(){
        return new HttpLogAspect();
    }

启动容器并访问

header参数可以通过curl验证。(注:2)

测试

可测试的代码才是好代码。所以其实我没写过几行好代码。。。

日志输出到控制台,可以通过获取控制台中内容进行contains验证

获取控制台输出字符

    ByteArrayOutputStream outContent = new ByteArrayOutputStream();
    System.setOut(new PrintStream(outContent));

执行request请求

基于spring test提供的mockMvc方法,测试代码如下:

    import org.junit.Before;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.http.MediaType;
    import org.springframework.test.context.junit4.SpringRunner;
    import org.springframework.test.web.servlet.MockMvc;
    import org.springframework.test.web.servlet.RequestBuilder;
    import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
    import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
    import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
    import org.springframework.test.web.servlet.setup.MockMvcBuilders;
    import org.springframework.web.context.WebApplicationContext;

    import java.io.ByteArrayOutputStream;
    import java.io.PrintStream;
    import java.util.Random;

    import static org.junit.Assert.assertFalse;
    import static org.junit.Assert.assertTrue;

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class UserApiTest {

        private MockMvc mockMvc;

        @Autowired
        private UserApi userApi;

        @Autowired
        private WebApplicationContext context;

        private ByteArrayOutputStream outContent;
        private int userId = new Random().nextInt(10);
        private int age = new Random().nextInt(10);
        private long username = new Random().nextLong();
        private long password = new Random().nextLong();

        @Before
        public void setup() {
            // 坚挺控制台输出
            outContent = new ByteArrayOutputStream();
            System.setOut(new PrintStream(outContent));

            //项目拦截器有效
            mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
            //单个类,拦截器无效
            // mockMvc = MockMvcBuilders.standaloneSteup(userApi).build();
        }


        @Test
        public void test_log() throws Exception {
            String path = "/user/log/" + userId;
            String uri = path + "?age=" + age;
            RequestBuilder request = MockMvcRequestBuilders.get(uri)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON);

            mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk());
            String console = outContent.toString();
            assertTrue(console.contains("REQUEST_LOG"));
            assertFalse(console.contains("HEADER_PARAMS"));
            assertTrue(console.contains("RESPONSE_LOG"));
            assertTrue(console.contains(path));
            assertTrue(console.contains(String.valueOf(age)));
            assertFalse(console.contains(String.valueOf(username)));
            assertFalse(console.contains(String.valueOf(password)));
        }

        @Test
        public void test_log_header() throws Exception {
            String path = "/user/log/pwd/" + userId;
            String uri = path + "?age=" + age;
            RequestBuilder request = MockMvcRequestBuilders.get(uri)
                    .header("username", username)
                    .header("password", password)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON);

            mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk());

            String console = outContent.toString();
            assertTrue(console.contains("REQUEST_LOG"));
            assertTrue(console.contains("HEADER_PARAMS"));
            assertTrue(console.contains("RESPONSE_LOG"));
            assertTrue(console.contains(path));
            assertTrue(console.contains(String.valueOf(age)));
            assertFalse(console.contains(String.valueOf(username)));
            assertTrue(console.contains(String.valueOf(password)));
        }

        @Test
        public void test_log_header_excludeResponse() throws Exception {
            String path = "/user/log/pwdExcludeResponse/" + userId;
            String uri = path + "?age=" + age;
            RequestBuilder request = MockMvcRequestBuilders.get(uri)
                    .header("username", username)
                    .header("password", password)
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .accept(MediaType.APPLICATION_JSON);

            mockMvc.perform(request).andExpect(MockMvcResultMatchers.status().isOk());

            String console = outContent.toString();
            assertTrue(console.contains("REQUEST_LOG"));
            assertTrue(console.contains("HEADER_PARAMS"));
            assertFalse(console.contains("RESPONSE_LOG"));
            assertTrue(console.contains(path));
            assertTrue(console.contains(String.valueOf(age)));
            assertTrue(console.contains(String.valueOf(username)));
            assertFalse(console.contains(String.valueOf(password)));
        }

    }

注:1 @EnableAspectJAutoProxy

注:2 curl增加header参数

curl -H 'username:123' -H 'password:345'

编写单元测试过程中,接口入参为照片文件byte[],想到的方法是在test/resources目录下,放好测试照片。代码编写遇到两个问题

1. 读取resources目录下文件

  ClassPathResource resource = new ClassPathResource("front.png");

2. Resource转换为byte[]

Resource有getFile()接口,InputStreamSource有getInputStream()接口,ClassPathResource作为子类,可以通过这两个接口方法,然后再进行转换。还有一个更简便的方法,通过Files工具读取path
注:Files读取的path不是ClassPathResource#getPath()的返回值,而是ClassPathResource#getURL()#getPath()的值

  byte[] bytes = Files.readAllBytes(Paths.get(resource.getURL().getPath()));