Druid连接池监控的一次改造

1. 背景

druid本身提供了监控功能,具体在我另一篇博文《Druid连接池监控》里有介绍。当时提到有以下缺陷:

  1. 无法灵活监控多个目标
  2. 切换环境不方便
  3. JMX重连不会成功

因此针对这些问题,对其进行改造。改造后的源码已经放在个人的github上:
https://github.com/bungder/druid-aggregated-monitor

对应本文的版本,已经打了tag:
https://github.com/bungder/druid-aggregated-monitor/releases/tag/0.0.1

对于在同一个工程里进行监控和展示的情况不进行考虑,具体原因见刚刚提到的博文。

2. 原理分析

首先,监控数据的展示是通过com.alibaba.druid.support.http.StatViewServlet实现的,将其源码贴上来分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181

package com.alibaba.druid.support.http;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.management.MBeanServerConnection;
import javax.management.ObjectName;
import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;
import javax.servlet.ServletException;

import com.alibaba.druid.stat.DruidStatService;
import com.alibaba.druid.support.logging.Log;
import com.alibaba.druid.support.logging.LogFactory;

/**
* 注意:避免直接调用Druid相关对象例如DruidDataSource等,相关调用要到DruidStatManagerFacade里用反射实现
*
* @author sandzhang<sandzhangtoo@gmail.com>
*/
public class StatViewServlet extends ResourceServlet {

private final static Log LOG = LogFactory.getLog(StatViewServlet.class);

private static final long serialVersionUID = 1L;

public static final String PARAM_NAME_RESET_ENABLE = "resetEnable";

public static final String PARAM_NAME_JMX_URL = "jmxUrl";
public static final String PARAM_NAME_JMX_USERNAME = "jmxUsername";
public static final String PARAM_NAME_JMX_PASSWORD = "jmxPassword";

private DruidStatService statService = DruidStatService.getInstance();

/** web.xml中配置的jmx的连接地址 */
private String jmxUrl = null;
/** web.xml中配置的jmx的用户名 */
private String jmxUsername = null;
/** web.xml中配置的jmx的密码 */
private String jmxPassword = null;
private MBeanServerConnection conn = null;

public StatViewServlet(){
super("support/http/resources");
}

public void init() throws ServletException {
super.init();

try {
String param = getInitParameter(PARAM_NAME_RESET_ENABLE);
if (param != null && param.trim().length() != 0) {
param = param.trim();
boolean resetEnable = Boolean.parseBoolean(param);
statService.setResetEnable(resetEnable);
}
} catch (Exception e) {
String msg = "initParameter config error, resetEnable : " + getInitParameter(PARAM_NAME_RESET_ENABLE);
LOG.error(msg, e);
}

// 获取jmx的连接配置信息
String param = readInitParam(PARAM_NAME_JMX_URL);
if (param != null) {
jmxUrl = param;
jmxUsername = readInitParam(PARAM_NAME_JMX_USERNAME);
jmxPassword = readInitParam(PARAM_NAME_JMX_PASSWORD);
try {
initJmxConn();
} catch (IOException e) {
LOG.error("init jmx connection error", e);
}
}

}

/**
* 读取servlet中的配置参数.
*
* @param key 配置参数名
* @return 配置参数值,如果不存在当前配置参数,或者为配置参数长度为0,将返回null
*/
private String readInitParam(String key) {
String value = null;
try {
String param = getInitParameter(key);
if (param != null) {
param = param.trim();
if (param.length() > 0) {
value = param;
}
}
} catch (Exception e) {
String msg = "initParameter config [" + key + "] error";
LOG.warn(msg, e);
}
return value;
}

/**
* 初始化jmx连接
*
* @throws IOException
*/
private void initJmxConn() throws IOException {
if (jmxUrl != null) {
JMXServiceURL url = new JMXServiceURL(jmxUrl);
Map<String, String[]> env = null;
if (jmxUsername != null) {
env = new HashMap<String, String[]>();
String[] credentials = new String[] { jmxUsername, jmxPassword };
env.put(JMXConnector.CREDENTIALS, credentials);
}
JMXConnector jmxc = JMXConnectorFactory.connect(url, env);
conn = jmxc.getMBeanServerConnection();
}
}

/**
* 根据指定的url来获取jmx服务返回的内容.
*
* @param connetion jmx连接
* @param url url内容
* @return the jmx返回的内容
* @throws Exception the exception
*/
private String getJmxResult(MBeanServerConnection connetion, String url) throws Exception {
ObjectName name = new ObjectName(DruidStatService.MBEAN_NAME);

String result = (String) conn.invoke(name, "service", new String[] { url },
new String[] { String.class.getName() });
return result;
}

/**
* 程序首先判断是否存在jmx连接地址,如果不存在,则直接调用本地的duird服务; 如果存在,则调用远程jmx服务。在进行jmx通信,首先判断一下jmx连接是否已经建立成功,如果已经
* 建立成功,则直接进行通信,如果之前没有成功建立,则会尝试重新建立一遍。.
*
* @param url 要连接的服务地址
* @return 调用服务后返回的json字符串
*/
protected String process(String url) {
String resp = null;
if (jmxUrl == null) {
resp = statService.service(url);
} else {
if (conn == null) {// 连接在初始化时创建失败
try {// 尝试重新连接
initJmxConn();
} catch (IOException e) {
LOG.error("init jmx connection error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR,
"init jmx connection error" + e.getMessage());
}
if (conn != null) {// 连接成功
try {
resp = getJmxResult(conn, url);
} catch (Exception e) {
LOG.error("get jmx data error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR, "get data error:"
+ e.getMessage());
}
}
} else {// 连接成功
try {
resp = getJmxResult(conn, url);
} catch (Exception e) {
LOG.error("get jmx data error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR,
"get data error" + e.getMessage());
}
}
}
return resp;
}

}

首先,现在需要搞清楚的问题有:

  1. 配置信息是如何生效的
  2. 监控数据是怎么流动的
  3. 权限控制是怎样实现的
  4. 为什么重连会失败

逐个方法去看,init方法是初始化的,应该能找到『配置信息是如何生效的』的答案。里面调用了readInitparam方法来读取,而这个方法又调用了getInitParameter方法,进入方法后发现此方法是javax.servlet.GenericServlet里的,已经不是druid的代码,意味着读取参数是通过调用容器的api实现的,这个过程无法进行篡改。

这里只是读取参数值,还没使用,让我们一步步回退回init方法,在读取了参数值之后就调用initJmxConn方法,该方法初始化了与监控目标之间的JMX连接,是关键的地方。但是里面也没多少东西,主要就是根据url去获取连接,对于『为什么重连会失败』,应该也是一个切入点。但是一路点进去看都没发现有重试的机制。

接着往下看,剩下getJmxResultprocess两个方法,其注释里已经讲得很明白了。可以发现,在process方法里有重连的机制,那么还是没搞清楚为什么无法重连成功。

在看完这个类之后,可以发现请求的调用链并没有体现出来,所以看它的父类ResourceServlet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/*
* Copyright 1999-2011 Alibaba Group Holding Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.alibaba.druid.support.http;

import com.alibaba.druid.support.http.util.IPAddress;
import com.alibaba.druid.support.http.util.IPRange;
import com.alibaba.druid.support.logging.Log;
import com.alibaba.druid.support.logging.LogFactory;
import com.alibaba.druid.util.StringUtils;
import com.alibaba.druid.util.Utils;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@SuppressWarnings("serial")
public abstract class ResourceServlet extends HttpServlet {

private final static Log LOG = LogFactory.getLog(ResourceServlet.class);

public static final String SESSION_USER_KEY = "druid-user";
public static final String PARAM_NAME_USERNAME = "loginUsername";
public static final String PARAM_NAME_PASSWORD = "loginPassword";
public static final String PARAM_NAME_ALLOW = "allow";
public static final String PARAM_NAME_DENY = "deny";
public static final String PARAM_REMOTE_ADDR = "remoteAddress";

protected String username = null;
protected String password = null;

protected List<IPRange> allowList = new ArrayList<IPRange>();
protected List<IPRange> denyList = new ArrayList<IPRange>();

protected final String resourcePath;

protected String remoteAddressHeader = null;

public ResourceServlet(String resourcePath){
this.resourcePath = resourcePath;
}

public void init() throws ServletException {
initAuthEnv();
}

private void initAuthEnv() {
String paramUserName = getInitParameter(PARAM_NAME_USERNAME);
if (!StringUtils.isEmpty(paramUserName)) {
this.username = paramUserName;
}

String paramPassword = getInitParameter(PARAM_NAME_PASSWORD);
if (!StringUtils.isEmpty(paramPassword)) {
this.password = paramPassword;
}

String paramRemoteAddressHeader = getInitParameter(PARAM_REMOTE_ADDR);
if (!StringUtils.isEmpty(paramRemoteAddressHeader)) {
this.remoteAddressHeader = paramRemoteAddressHeader;
}

try {
String param = getInitParameter(PARAM_NAME_ALLOW);
if (param != null && param.trim().length() != 0) {
param = param.trim();
String[] items = param.split(",");

for (String item : items) {
if (item == null || item.length() == 0) {
continue;
}

IPRange ipRange = new IPRange(item);
allowList.add(ipRange);
}
}
} catch (Exception e) {
String msg = "initParameter config error, allow : " + getInitParameter(PARAM_NAME_ALLOW);
LOG.error(msg, e);
}

try {
String param = getInitParameter(PARAM_NAME_DENY);
if (param != null && param.trim().length() != 0) {
param = param.trim();
String[] items = param.split(",");

for (String item : items) {
if (item == null || item.length() == 0) {
continue;
}

IPRange ipRange = new IPRange(item);
denyList.add(ipRange);
}
}
} catch (Exception e) {
String msg = "initParameter config error, deny : " + getInitParameter(PARAM_NAME_DENY);
LOG.error(msg, e);
}
}

public boolean isPermittedRequest(String remoteAddress) {
boolean ipV6 = remoteAddress != null && remoteAddress.indexOf(':') != -1;

if (ipV6) {
return "0:0:0:0:0:0:0:1".equals(remoteAddress) || (denyList.size() == 0 && allowList.size() == 0);
}

IPAddress ipAddress = new IPAddress(remoteAddress);

for (IPRange range : denyList) {
if (range.isIPAddressInRange(ipAddress)) {
return false;
}
}

if (allowList.size() > 0) {
for (IPRange range : allowList) {
if (range.isIPAddressInRange(ipAddress)) {
return true;
}
}

return false;
}

return true;
}

protected String getFilePath(String fileName) {
return resourcePath + fileName;
}

protected void returnResourceFile(String fileName, String uri, HttpServletResponse response)
throws ServletException,
IOException {

String filePath = getFilePath(fileName);
if (fileName.endsWith(".jpg")) {
byte[] bytes = Utils.readByteArrayFromResource(filePath);
if (bytes != null) {
response.getOutputStream().write(bytes);
}

return;
}

String text = Utils.readFromResource(filePath);
if (text == null) {
response.sendRedirect(uri + "/index.html");
return;
}
if (fileName.endsWith(".css")) {
response.setContentType("text/css;charset=utf-8");
} else if (fileName.endsWith(".js")) {
response.setContentType("text/javascript;charset=utf-8");
}
response.getWriter().write(text);
}

public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
String requestURI = request.getRequestURI();

response.setCharacterEncoding("utf-8");

if (contextPath == null) { // root context
contextPath = "";
}
String uri = contextPath + servletPath;
String path = requestURI.substring(contextPath.length() + servletPath.length());

if (!isPermittedRequest(request)) {
path = "/nopermit.html";
returnResourceFile(path, uri, response);
return;
}

if ("/submitLogin".equals(path)) {
String usernameParam = request.getParameter(PARAM_NAME_USERNAME);
String passwordParam = request.getParameter(PARAM_NAME_PASSWORD);
if (username.equals(usernameParam) && password.equals(passwordParam)) {
request.getSession().setAttribute(SESSION_USER_KEY, username);
response.getWriter().print("success");
} else {
response.getWriter().print("error");
}
return;
}

if (isRequireAuth() //
&& !ContainsUser(request)//
&& !("/login.html".equals(path) //
|| path.startsWith("/css")//
|| path.startsWith("/js") //
|| path.startsWith("/img"))) {
if (contextPath.equals("") || contextPath.equals("/")) {
response.sendRedirect("/druid/login.html");
} else {
if ("".equals(path)) {
response.sendRedirect("druid/login.html");
} else {
response.sendRedirect("login.html");
}
}
return;
}

if ("".equals(path)) {
if (contextPath.equals("") || contextPath.equals("/")) {
response.sendRedirect("/druid/index.html");
} else {
response.sendRedirect("druid/index.html");
}
return;
}

if ("/".equals(path)) {
response.sendRedirect("index.html");
return;
}

if (path.contains(".json")) {
String fullUrl = path;
if (request.getQueryString() != null && request.getQueryString().length() > 0) {
fullUrl += "?" + request.getQueryString();
}
response.getWriter().print(process(fullUrl));
return;
}

// find file in resources path
returnResourceFile(path, uri, response);
}

public boolean ContainsUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return session != null && session.getAttribute(SESSION_USER_KEY) != null;
}

public boolean isRequireAuth() {
return this.username != null;
}

public boolean isPermittedRequest(HttpServletRequest request) {
String remoteAddress = getRemoteAddress(request);
return isPermittedRequest(remoteAddress);
}

protected String getRemoteAddress(HttpServletRequest request) {
String remoteAddress = null;

if (remoteAddressHeader != null) {
remoteAddress = request.getHeader(remoteAddressHeader);
}

if (remoteAddress == null) {
remoteAddress = request.getRemoteAddr();
}

return remoteAddress;
}

protected abstract String process(String url);
}

其中,service方法里有URL的判断,方法里有request和response,看上去就是流程的起点,但是一般我们写servlet都是从doGetdoPost入手的,这里面不知道做了什么封装,于是继续往父类去看,发现其父类是javax.servlet.http.HttpServlet,已经是J2EE定义的类了,我用的是tomcat容器,所以这个类由tomcat提供。里面有常见的doPostdoGet方法

可以看到,doGetdoPost方法默认都是不通的:

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

protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException
{
String protocol = req.getProtocol();
String msg = lStrings.getString("http.method_get_not_supported");
if (protocol.endsWith("1.1")) {
resp.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, msg);
} else {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
}
}

protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {

String protocol = req.getProtocol();
String msg = lStrings.getString("http.method_post_not_supported");
if (protocol.endsWith("1.1")) {
resp.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, msg);
} else {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
}
}

赶紧去看注释:

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

/**
* Called by the server (via the <code>service</code> method) to
* allow a servlet to handle a GET request.
*
* <p>Overriding this method to support a GET request also
* automatically supports an HTTP HEAD request. A HEAD
* request is a GET request that returns no body in the
* response, only the request header fields.
*
* <p>When overriding this method, read the request data,
* write the response headers, get the response's writer or...
* /
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException
{
//...
}


/**
* Called by the server (via the <code>service</code> method)
* to allow a servlet to handle a POST request.
*
* ...
*
* <p>When overriding this method, read the request data,
* write the response headers...
*
*/
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
//...
}

原来这些方法是由service方法调用的,并且需要自己覆盖,很符合我们一贯的经验。再看看service方法是怎么回事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

/**
* Receives standard HTTP requests from the public
* <code>service</code> method and dispatches
* them to the <code>do</code><i>Method</i> methods defined in
* this class. This method is an HTTP-specific version of the
* {@link javax.servlet.Servlet#service} method. There's no
* need to override this method.
*
* @param req the {@link HttpServletRequest} object that
* contains the request the client made of
* the servlet
*
* @param resp the {@link HttpServletResponse} object that
* contains the response the servlet returns
* to the client
*
* @exception IOException if an input or output error occurs
* while the servlet is handling the
* HTTP request
*
* @exception ServletException if the HTTP request
* cannot be handled
*
* @see javax.servlet.Servlet#service
*/
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
//...
}


这个方法是请求的统一接收入口,然后将请求分发到doGetdoPostdoHead等对应标准HTTP请求方法的方法去。注释里特地说明了没有必要覆盖这个方法,druid的开发者很直接粗暴,不管你请求方法是什么,全部一刀切,反正这玩意儿要求不高。总之,现在我们知道了,service方法就是请求的入口,这样我们再回去看看com.alibaba.druid.support.http.ResourceServletservice方法,通过这个方法应该就能理顺整个流程。

将其方法代码加上我自己的注释贴出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95

/**
* 整个方法其实做的还是路由分发的工作,根据请求的地址,分别返回不同的资源,并且进行访问控制。
*/
public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String contextPath = request.getContextPath();
String servletPath = request.getServletPath();
String requestURI = request.getRequestURI();

response.setCharacterEncoding("utf-8");

if (contextPath == null) { // root context
contextPath = "";
}
String uri = contextPath + servletPath;
String path = requestURI.substring(contextPath.length() + servletPath.length());

// 禁止访问的时候返回nopermit.html,returnResourceFile这个方法很关键,下文说说
if (!isPermittedRequest(request)) {
path = "/nopermit.html";
returnResourceFile(path, uri, response);
return;
}

/* 从名字看来,这是接收登录请求的
* 很明显,校验就是匹配username和password是否都与配置的匹配,如果匹配就在session里塞点标识
* 很原始的做法,但是对于一个内部使用的线程池监控来说也不用做得太复杂
*/
if ("/submitLogin".equals(path)) {
String usernameParam = request.getParameter(PARAM_NAME_USERNAME);
String passwordParam = request.getParameter(PARAM_NAME_PASSWORD);
if (username.equals(usernameParam) && password.equals(passwordParam)) {
request.getSession().setAttribute(SESSION_USER_KEY, username);
response.getWriter().print("success");
} else {
response.getWriter().print("error");
}
return;
}

/* 拦截登录 */
if (isRequireAuth() //
&& !ContainsUser(request)//
&& !("/login.html".equals(path) //
|| path.startsWith("/css")//
|| path.startsWith("/js") //
|| path.startsWith("/img"))) {
if (contextPath.equals("") || contextPath.equals("/")) {
response.sendRedirect("/druid/login.html");
} else {
if ("".equals(path)) {
response.sendRedirect("druid/login.html");
} else {
response.sendRedirect("login.html");
}
}
return;
}

// 缺省首页的跳转
if ("".equals(path)) {
if (contextPath.equals("") || contextPath.equals("/")) {
response.sendRedirect("/druid/index.html");
} else {
response.sendRedirect("druid/index.html");
}
return;
}


if ("/".equals(path)) {
response.sendRedirect("index.html");
return;
}


/*
* 在不改造的时候,正常监控一个druid实例,会发现页面的数据都是异步刷新的,
* 通过浏览器的开发者工具能发现取数据的请求都是json后缀的,所以这里就是监控数据流动的节点
* process方法是关键,而这个方法是一个抽象方法,由具体的实现类来实现,下文将回到{@link com.alibaba.druid.support.http.StatViewServlet#process(String)}方法里看
* /
if (path.contains(".json")) {
String fullUrl = path;
if (request.getQueryString() != null && request.getQueryString().length() > 0) {
fullUrl += "?" + request.getQueryString();
}
response.getWriter().print(process(fullUrl));
return;
}

// 在以上情况都不匹配的时候,返回资源文件
// find file in resources path
returnResourceFile(path, uri, response);
}

有两个方法需要看:

  1. returnResourceFile
  2. process

returnResourceFile方法在ResourceServlet里面实现了(注释是我加的):

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
protected void returnResourceFile(String fileName, String uri, HttpServletResponse response)                                                                       throws ServletException, IOException {

String filePath = getFilePath(fileName);
// 如果是jpg,则返回流
if (fileName.endsWith(".jpg")) {
byte[] bytes = Utils.readByteArrayFromResource(filePath);
if (bytes != null) {
response.getOutputStream().write(bytes);
}

return;
}

/*
* 否则读取文件,返回文件内的文本
* 其中,Utils.readFromResource有这么关键的一行
* Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
* 这和servlet初始化的时候是有关的
* ResourceServlet本身也是一个抽象类,其子类StatViewServlet初始化的时候指定了资源目录的路径:
* public StatViewServlet(){
* super("support/http/resources");
* }
*/
String text = Utils.readFromResource(filePath);
if (text == null) {
// 如果请求的路径映射不到资源文件,则调到默认首页(其实就是将404指向了index.html)
response.sendRedirect(uri + "/index.html");
return;
}

// 如果是css或者是js文件,则还需要设置相应的响应头部
if (fileName.endsWith(".css")) {
response.setContentType("text/css;charset=utf-8");
} else if (fileName.endsWith(".js")) {
response.setContentType("text/javascript;charset=utf-8");
}
response.getWriter().write(text);
}

接着看process方法(注释是原有的):

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

/**
* 程序首先判断是否存在jmx连接地址,如果不存在,则直接调用本地的duird服务; 如果存在,则调用远程jmx服务。在进行jmx通信,首先判断一下jmx连接是否已经建立成功,如果已经
* 建立成功,则直接进行通信,如果之前没有成功建立,则会尝试重新建立一遍。.
*
* @param url 要连接的服务地址
* @return 调用服务后返回的json字符串
*/
protected String process(String url) {
String resp = null;
if (jmxUrl == null) {
resp = statService.service(url);
} else {
if (conn == null) {// 连接在初始化时创建失败
try {// 尝试重新连接
initJmxConn();
} catch (IOException e) {
LOG.error("init jmx connection error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR,
"init jmx connection error" + e.getMessage());
}
if (conn != null) {// 连接成功
try {
resp = getJmxResult(conn, url);
} catch (Exception e) {
LOG.error("get jmx data error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR, "get data error:"
+ e.getMessage());
}
}
} else {// 连接成功
try {
resp = getJmxResult(conn, url);
} catch (Exception e) {
LOG.error("get jmx data error", e);
resp = DruidStatService.returnJSONResult(DruidStatService.RESULT_CODE_ERROR,
"get data error" + e.getMessage());
}
}
}
return resp;
}

可见它是有重连的,而保证了连接成功之后,获取数据的方法是getJmxResult,这个是在StatViewServlet里面实现的:

1
2
3
4
5
6
7
private String getJmxResult(MBeanServerConnection connetion, String url) throws Exception {
ObjectName name = new ObjectName(DruidStatService.MBEAN_NAME);

String result = (String) conn.invoke(name, "service", new String[] { url },
new String[] { String.class.getName() });
return result;
}

所以实际上就是用MBeanServer的连接去直接取数据然后原样返回,所有的监控数据其实是缓存在被监控的目标处的,web的监控只是一个请求转发与展示的作用。

所以现在总结StatViewServlet整个工作的主要过程:

  1. 记录用户名和密码
  2. 根据配置的jmxUrl初始化jmx连接
  3. 接收请求,分发请求
  4. 如果请求是json数据请求,则通过jmx连接到被监控对象处取数据,然后返回

3. 改进思路

3.1 思路一:动态创建并注册StatViewServlet

这是首先想到的思路,因为使用这种办法不需要对druid的web监控细节了解多少。要实现这个目标,需要做到以下两点之一:

  1. 对于Java Web容器的启动过程很了解,并且深入细节
  2. Google能找到相似的例子

第一点我还做不到,短时间内也做不到,所以只能往第二点去努力。找到了一个最贴切的办法是:
Dynamic Servlet Registration Example

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
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.ServletRegistration;
import javax.servlet.annotation.WebListener;

@WebListener
public class ServletContextListenerImpl implements ServletContextListener {

@Override
public void contextInitialized(final ServletContextEvent sce) {
final ServletContext servletContext = sce.getServletContext();
final ServletRegistration.Dynamic dynamic = servletContext.addServlet("Example Servlet", ExampleServlet.class);
dynamic.addMapping("/");

final Map<String, ? extends ServletRegistration> map = servletContext.getServletRegistrations();
for (String key : map.keySet()) {
servletContext.log("Registered Servlet: " + map.get(key).getName());
}
}

@Override
public void contextDestroyed(final ServletContextEvent sce) {
//NO-OP
}
}

主要就是实现javax.servlet.ServletContextListener,通过javax.servlet.ServletContextEvent实例获取javax.servlet.ServletContext实例,然后调用它的方法去注册新的servlet。

看上去好像可行的样子,但是在实际运行起来之后,在这一行报错了:

1
final ServletRegistration.Dynamic dynamic = servletContext.addServlet("Example Servlet", ExampleServlet.class);

错误信息没有记录下来,但是意思就是说这个操作是不支持的,反正就是没戏。具体为什么,还需要进一步了解。

3.2 思路二:修改StatViewServlet的机制

在第一个思路走不通之后,只能从其工作机制上入手。其原理分析已经在上文给出。

因为只要有JMX的连接就可以获取数据了,所以关键在于以下几点:

  1. 持有多个jmx连接并且与不同的请求关联起来
  2. 根据配置去动态创建连接
  3. 将原本固定的几个页面与配置的多个监控对象动态地对应起来
  4. 配置能根据部署环境的不同而改变,并且发生变更的时候能轻易修改
  5. 列出所有被监控对象

对于第一点,创建jmx连接只要有jmxUrl就够了,所以很容易做到,至于与请求关联起来,其实就是从请求的url里提取特征,用于表示不同的监控对象,然后将此特征映射到对应的jmx连接即可。

对于第二点,这其实就是普通的读取、解析配置,然后用配置信息去初始化jmx连接(当然还有登录名、密码和黑白名单等)。

第三点,转下弯,原有的逻辑是将url直接映射为资源文件,只要在这中间加一层解析即可。

第四点,简单的方案是配置多个配置文件,根据不同的环境打不同的包。但是这种做法不灵活,最好还是做成注册中心的形式,被监控对象启动的时候网注册中心写入信息,这边从注册中心读,还有下线机制。但是这种做法工作量大,而且要改被监控的一方,容易引入bug。要不就与配置中心集成,这样就只需改动web监控一端即可。这里的配置方案有多种,很适合采用SPI。

第五点,根据配置信息做个汇总,然后给个页面列出来就可以。

4 实现

上文提及的问题在这里基本上都解决了,思路都讲清楚了,实现就不再重复讲。请移步我github的仓库:https://github.com/bungder/druid-aggregated-monitor

在这里提提失败重连的问题

4.1重连失败的问题

其重连失败的问题,在debug的时候发现其实并没有重连,它重连的条件是conn为null,但是实际上conn初始化之后就不会为null了,但是当连接失效之后,里面的terminated属性为true,而MBeanServerConnection是一个interface,本身没定义操作这个属性的方法,并且至少有两个类实现了这个接口,运行时的实际类型并不确定是否总是某个实现类型,所以也不好去强转类型进行操作。但是可以利用它本身的逻辑:既然它触发重连的条件是conn为null,我就将它设成null好了。当conn不为null但是获取数据又出错的时候,就可以判断连接有问题,不妨触发重试,即使这种情况下不一定是连接失效了,但是正常情况下不会出现这种现象,就将其当成是连接失效也无妨。