wangyuheng's Blog

time for change

背景

公司为了快速上线,几个月前通过docker镜像 kafka 部署了一台kafka-borker。Dockerfile如下

1
2
3
4
5
6
7
8
FROM wurstmeister/kafka:latest

ENV KAFKA_MESSAGE_MAX_BYTES=21474836
ENV KAFKA_AUTO_CREATE_TOPICS_ENABLE=true
ENV KAFKA_CREATE_TOPICS=test:1:1
ENV KAFKA_ZOOKEEPER_CONNECT=zk1:2181

VOLUME ["/kafka"]

随着业务的增长,单节点的kafka-broker已经成为一个潜在的可靠性隐患。所以借着N年难得一遇的停机维护,决定把kafka-broker扩容为主备集群模式,提升稳定性,并为日后的分区扩容提供可能(不停服)。

Read more »

如何搭建一个监控系统

生产环境必须是可监控的,一个对开发者黑盒的线上应用无异于灾难。一个简单的监控系统大致包含以下几部分:

  1. 采集数据
  2. 保存数据
  3. 数据可视化
  4. 监控告警

m0

Read more »

前提

微服务通过http协议进行内容传递,因为http协议自由且松散,所以有必要提供一套强有力的约定来限制接口。
服务内部实现,可以基于个人编程风格,但涉及到交互协作,必须提供风格一致的输入输出,减少开发与维护成本。

Read more »

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 插件运行。

1
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文件中配置

1
2
3
4
<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类中自动注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@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中添加配置项

1
2
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规则。

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

3.3 spring-boot 运行war项目

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

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
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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插件运行。

1
mvn spring-boot:run

3.4 自定义请求后缀匹配

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

1
2
3
4
5
6
7
8
9
10
   <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配置

1
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.*".
     */
1
spring.mvc.pathmatch.use-suffix-pattern=true

3.5 使用内存数据库H2

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

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

1
2
3
4
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>

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

1
2
3
4
5
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 微信接口

企业微信相关配置如下

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

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

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
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;
}

}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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();
}

}
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
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页面

1
2
3
4
5
6
7
8
9

@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 白名单校验

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
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 接口参数签名校验

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
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加密工具封装

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
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去掉验权操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("common")
@Anonymous
public class CommonController {

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

2.4 健康检测

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

通过Scheduled编写定时任务

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
@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并允许启动调度

1
2
3
4
5
6
7
8
9
@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 »
0%