做java的,一般小项目都是使用logback配置控制台、文件等对日志进行记录和持久化,但是随着微服务架构的越来越流行,各种微服务模块越来越多,节点也越来越多,线上日志的排查和访问统计也跟着变的更复杂和不便。
于是想着研究有什么更好的架构方案,网上找了一圈,质量参差不齐,但得到一个结论是,用elk(即elastic+logstash+kibana)是现在比较流行的方法。研究了一下,觉得应该不错,于是便着手开搞。
先介绍一下以前的流程,由于本文主要介绍api接口访问日志的保存和分析,故后台的系统日志就不做介绍了。
上面这张图介绍的是老版本的流程,该系统是众多微服务之一,并且未架设网关。主要有几个问题:
1.未做限流处理,可能导致redis崩掉
2.及时性不够高,因为定时任务间隔较短
3.可能导致db cpu100%,因为db只有一个节点,所有读写、后台的操作都在此上进行
4.扩展性不好,此时如果需要记录别的微服务,得再来一遍
5.每新增一个新的维度数据分析统计,需要前后端配合开发进行可视化,效率低
为了解决上述问题,尝试了几种方案。
方案1:
logback配置直接输出到logstash,再通过logstash输出到es,再通过kibana展示。然后由于日志需要长期保存,为免es出现问题(es才用不久,了解不深),将es中数据再同步到db。
遇到问题:
log.info(RequestModel)这样打日志,RequestModel中的信息会在logstash中单独以一个"message"字段进行保存,这对后面kibana写过滤表达式很难处理。后来采取java代码中"log.info("{} {} {}", request_uri, request_ip, request_param…)"这种日志产生后,仅希望将这些request_*字段输出至logstash,多余的字段不要,可用grok过滤,但一些自定义的字段不太会处理,正则表达式也不怎么会用,最终放弃。(其实是想到了别的方案)
方案2:
既然日志需要保存到db,那么logback配置就改为输出到db,再把数据传到es,再通过kibana展示。
遇到问题:仍然是关于仅保存自定义字段的需求,网上了解了一圈,默认是要3张表,肯定不是我要的;还有用继承重写的,但看起来仍然没有达到我的要求。并且还有一个连接池的问题没有解决,就也没有研究下去。
方案3:
既然日志需要自定义字段,这些中间件我又用不溜,那就自己处理吧。获取到请求信息以后,手动持久化到db,再把数据传到es,再通过kibana展示。
经过一番折腾,最终成功实现了方案3。
也介绍一下新的架构:
改造关键点如下:
1.最前端架设了一道网关,所有的请求都通过网关进行转发,这样在网关层,就能统一实现限流、鉴权、熔断等,当然包括本文讲的日志。
2.日志获取处理完成后,在中间加一道阻塞式队列,用以保存日志和缓冲,要注意线程有关的配置和同步。
3.db脱离主业务db,专门另开一个db用以保存,且日志表不带任何索引(除主键)。
下面上主要的代码:
基础过滤类
package com.suitwin.gateway.zuul;
import java.util.ArrayList;
import java.util.List;
import com.netflix.zuul.ZuulFilter;
/**
* @desc 抽象基础过滤,放置常量,供自定义的两种过滤器集成共用
* @auth kerry.wang
* @date 2021年4月18日
*/
public abstract class ZuulFilterBase extends ZuulFilter {
protected static final String KEY_BEFOREFILTERRESPONSE = "beforeFilterResponse";
protected static final String KEY_REQS = "reqs";
protected static final String UTF8 = "UTF-8";
protected static final String CONTENTTYPE = "Content-type";
protected static final String APPLICATIONJSON = "application/json;charset=utf-8";
// 需要过滤做日志的微服务名称列表
protected final static List<String> SERVICELIST = new ArrayList<String>(7) {
private static final long serialVersionUID = -1153328974427768155L;
{
add("suitwin-epcv31");
add("suitwin-epc");
add("suitwin-amc");
add("suitwin-bmc");
add("suitwin-dmc");
add("suitwin-mms");
add("suitwin-trace");
}};
}
网关转发的预过滤
package com.suitwin.gateway.zuul;
import java.io.IOException;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.suitwin.common.constant.ConstResponse;
import com.suitwin.common.util.RedisUtil;
import com.suitwin.common.util.StringUtil;
import com.suitwin.gateway.util.JwtUtil;
/**
* @desc 自定义网关过滤器,转发前的过滤
* @auth kerry.wang
* @date 2019年8月8日
*/
@Component
public class ZuulFiltersBefore extends ZuulFilterBase {
@Resource(name="gatewayJwt")
private JwtUtil jwt;
@Override
public boolean shouldFilter() {
return true;// 设置开启过滤
}
/**
* 这里做令牌验证
*/
@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
// 保存请求的时间戳
ctx.set(KEY_REQS, System.currentTimeMillis());
// 如果需要令牌,拦截并校验
if(tokenRequired(ctx)) {
if(validToken(ctx)) {
ctx.setSendZuulResponse(true);
}
}else {
ctx.setSendZuulResponse(true);
}
return null;
}
@Override
public String filterType() {
return FilterConstants.PRE_TYPE;// 代表在路由转发之前进行过滤
}
@Override
public int filterOrder() {
return 0;// 值越小,优先级越高
}
/**
* @desc 验证uri是否需要令牌校验
* @auth kerry.wang
* @date 2021年4月9日
* @param request_uri
* @return
*/
private boolean tokenRequired(RequestContext ctx) {
String request_uri = ctx.getRequest().getRequestURI();
for(String ignoreUri : ExcludePath.excludePathList) {
if(request_uri.startsWith(ignoreUri) || request_uri.endsWith(ignoreUri)) {
return false;
}
}
return true;
}
/**
* @desc 验证令牌
* @auth kerry.wang
* @date 2021年4月9日
* @param request
*/
private boolean validToken(RequestContext ctx) {
HttpServletRequest request = ctx.getRequest();
String token = request.getHeader("token");
String requestUri = request.getRequestURI();
// token为空
if (StringUtil.isEmpty(token)) {
return interceptRouter(ctx, ConstResponse.TOKEN_EMPTY.code(), ConstResponse.TOKEN_EMPTY.des(), requestUri);
}
// token过期
String currentUser = RedisUtil.get(token);
if (StringUtil.isEmpty(currentUser)) {
return interceptRouter(ctx, ConstResponse.TOKEN_EXPIRED.code(), ConstResponse.TOKEN_EXPIRED.des(), requestUri);
}
String username = JwtUtil.getUsername(token);
Integer companyID = JwtUtil.getCompanyID(token);
// 验证令牌是否有效
boolean verifyToken = companyID == null ? jwt.verify(token, username) : jwt.verify(token, username, companyID);
if (!verifyToken) {
return interceptRouter(ctx, ConstResponse.TOKEN_INVALID.code(), ConstResponse.TOKEN_INVALID.des(), requestUri);
}
if(StringUtil.notEmpty(username)) {
ctx.set("username", username);// 保存请求的用户
}
if(companyID != null) {
ctx.set("companyID", companyID);// 保存请求的公司
}
String requestFrom = JwtUtil.getPlatform(token);
if(StringUtil.notEmpty(requestFrom)) {
ctx.set("requestFrom", requestFrom);
}
return true;
}
/**
* @desc 拦截路由
* @auth kerry.wang
* @date 2021年4月9日
* @param ctx
* @param code
* @param msg
* @param requestUri
*/
private boolean interceptRouter(RequestContext ctx, int code, String msg, String requestUri) {
String respJson = "{\"code\":" + code + ", \"msg\":\"" + requestUri + " => " + msg + "\", \"data\":null}";
// 在转发前被截断,做好标记,这样在转发后的拦截器中,就不再设置响应数据
ctx.set(KEY_BEFOREFILTERRESPONSE, respJson);
// 设置是否对请求进行路由
ctx.setSendZuulResponse(false);
ctx.setResponseStatusCode(200);
try {
ctx.getResponse().addHeader(CONTENTTYPE, APPLICATIONJSON);
ctx.getResponse().getWriter().write(respJson);
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
}
网关转发响应后的过滤
package com.suitwin.gateway.zuul;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.cloud.netflix.zuul.filters.support.FilterConstants;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.suitwin.common.util.IPUtil;
import com.suitwin.common.util.StringUtil;
import com.suitwin.common.util.TimeUtil;
import com.suitwin.gateway.model.AccessLog;
import com.suitwin.gateway.service.ILogService;
/**
* @desc 网关过滤器,获取转发后的响应之前的过滤
* @auth kerry.wang
* @date 2021年4月13日
*/
@Component
public class ZuulFiltersAfter extends ZuulFilterBase {
@Autowired
private RouteLocator routeLocator;
@Resource(name="dblog")
private ILogService dbLog;
@Override
public boolean shouldFilter() {
return true;// 设置开启过滤
}
@Override
public Object run() throws ZuulException {
// 获取转发请求后的响应数据
RequestContext ctx = RequestContext.getCurrentContext();
InputStream stream = ctx.getResponseDataStream();
String body = null;
try {
body = StreamUtils.copyToString(stream, Charset.forName(UTF8));
} catch (IOException e) {
e.printStackTrace();
}
AccessLog accessLog = parseRequestInfo(ctx);
if(accessLog != null) {
// 是否已被ZuulFiltersBefore拦截并处理响应
boolean hasInterceptedByBeforeFilter = ctx.containsKey(KEY_BEFOREFILTERRESPONSE);
// 异步记录访问日志到logstash
// logstashLog.doLog(log, body);
// 异步塞入log队列
dbLog.doLog(accessLog, hasInterceptedByBeforeFilter ? String.valueOf(ctx.get(KEY_BEFOREFILTERRESPONSE)) : body);
// 通过前面转发前的拦截,对ctx是否配置了sendZuulResponse判断是否令牌验证正常。如果这里不加这段处理,ctx.setResponseBody将出错,response.getWriter has called...
if(hasInterceptedByBeforeFilter) {
return null;
}
}
// 将转发后的响应set回网关对请求的响应,不set的话,本次请求将没有任何相应数据
ctx.setResponseBody(body);
return null;
}
@Override
public String filterType() {
return FilterConstants.POST_TYPE;// 代表在路由转发并响应后进行过滤
}
@Override
public int filterOrder() {
return 0;
}
/**
* @desc 解析请求信息获得请求日志
* @auth kerry.wang
* @date 2021年4月12日
* @param ctx
* @return
*/
private AccessLog parseRequestInfo(RequestContext ctx) {
HttpServletRequest req = ctx.getRequest();
// 获取转发到的微服务名称
Route route = routeLocator.getMatchingRoute(req.getRequestURI());
if(!doFilter(route.getLocation())) {
return null;
}
// ip位置信息
String ip = IPUtil.getIpAddr(req);
String country = "", province = "", city = "";
if(StringUtil.notEmpty(ip)) {
String ipInfo = IPUtil.getIpInfo(ip);
if(StringUtil.notEmpty(ipInfo) && ipInfo.contains("|")) {
// 中国|0|江苏|无锡|电信
country = ipInfo.split("\\|")[0];
province = ipInfo.split("\\|")[2];
city = ipInfo.split("\\|")[3];
}
}
// 请求耗时
Long requestCost = System.currentTimeMillis() - Long.valueOf(String.valueOf(ctx.get(KEY_REQS)));
AccessLog accessLog = new AccessLog();
accessLog.setRequestUri(req.getRequestURI());
accessLog.setRequestUrl(req.getRequestURL().toString());
accessLog.setRequestIp(ip);
accessLog.setRequestCountry(country);
accessLog.setRequestProvince(province);
accessLog.setRequestCity(city);
accessLog.setRequestType(req.getMethod());
accessLog.setRequestTime(TimeUtil.getCurrentDatetime_safety());
accessLog.setRequestCost(requestCost);
accessLog.setRequestService(route.getLocation());
// 获取请求体参数
try {
InputStream in = req.getInputStream();
String reqbody = StreamUtils.copyToString(in, Charset.forName(UTF8));
if(StringUtil.notEmpty(reqbody)) {
accessLog.setRequestParamBoby(reqbody);
}
}catch (Exception e) {
e.printStackTrace();
}
// 获取请求url上的参数
String queryString = req.getQueryString();
if(StringUtil.notEmpty(queryString)) {
accessLog.setRequestParamQuery(queryString);
}
if(ctx.containsKey("username")) {
accessLog.setRequestUser(String.valueOf(ctx.get("username")));
}
if(ctx.containsKey("companyID")) {
accessLog.setRequestCompany(String.valueOf(ctx.get("companyID")));
}
if(ctx.containsKey("requestFrom")) {
accessLog.setRequestFrom(String.valueOf(ctx.get("requestFrom")));
}
return accessLog;
}
/**
* @desc 判断是否是需要过滤的微服务
* @auth kerry.wang
* @date 2021年4月16日
* @param service
* @return
*/
private boolean doFilter(String service) {
return SERVICELIST.contains(service);
}
}
日志业务处理保存
package com.suitwin.gateway.log;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson.JSONObject;
import com.suitwin.common.util.StringUtil;
import com.suitwin.gateway.model.AccessLog;
import com.suitwin.gateway.service.ILogService;
import lombok.extern.slf4j.Slf4j;
@Component("dblog")
@Slf4j
public class DBLog implements ILogService {
@Async("logExecutor")
@Override
public void doLog(AccessLog accessLog, String responseJson) {
accessLog = convertRequestCompanyInfo(accessLog);
accessLog = parseResponseInfo(accessLog, responseJson);
try {
LogTask.LOGQUEUE.put(accessLog);
} catch (InterruptedException e) {
log.error("===>>> logqueue put failed, " + e.getMessage());
}
}
/**
* @desc 将请求中保存的公司id转换为公司名称
* @auth kerry.wang
* @date 2021年4月13日
* @param accessLog
* @return
*/
private AccessLog convertRequestCompanyInfo(AccessLog accessLog) {
if(StringUtil.notEmpty(accessLog.getRequestUser())) {
}
if(StringUtil.notEmpty(accessLog.getRequestCompany())) {
// 从db中根据公司id读取出公司名
// accessLog.setRequestCompany(requestCompany);
// 读取出公司所属行业
// accessLog.setRequestUserIndustry(requestUserIndustry);
}
return accessLog;
}
/**
* @desc 解析响应信息
* @auth kerry.wang
* @date 2021年4月13日
* @param accessLog
* @param responseJson
* @return
*/
private AccessLog parseResponseInfo(AccessLog accessLog, String responseJson) {
if(StringUtil.notEmpty(responseJson)) {
JSONObject jo = JSONObject.parseObject(responseJson);
Integer requestResultcode = jo.getInteger("code");
accessLog.setRequestResultcode(requestResultcode);
if(requestResultcode == 100) {
// 对vin、点选、oe搜索和零件关键词搜索接口做拦截,提取出其中的车辆有关信息
String uri = accessLog.getRequestUri();
if(uri.contains("parseVin/v")) {
jo = JSONObject.parseObject(jo.getString("data"));
accessLog.setRequestBrand(jo.getString("brand"));
accessLog.setRequestFactory(jo.getString("factory"));
accessLog.setRequestSeries(jo.getString("series"));
}
else if(uri.contains("getEpcModel/v")) {
}
else if(uri.contains("searchOe/v")) {
}
else if(uri.contains("xxxxx")) {
}
}
}
return accessLog;
// 记点,存入nosql
}
}
log持久化任务
package com.suitwin.gateway.log;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.suitwin.gateway.dao2.LogDao;
import com.suitwin.gateway.model.AccessLog;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
public class LogTask {
private LogTask() {};
// log队列
public static BlockingQueue<AccessLog> LOGQUEUE = new LinkedBlockingQueue<AccessLog>(5000);
// 待插入log集合
private volatile List<AccessLog> LOGLIST = new CopyOnWriteArrayList<AccessLog>();
@Autowired
private LogDao logDao;
// 最后一次日志插入时间
private long lastLogTimeMillions = 0;
// 定时任务间隔时间
private final static int CHECKINTEVAL = 5 * 1000;
// 空闲时间
private final static int FREETIME = 30 * 1000;
// 批量插入大小
private final static int BATCHSIZE = 1000;
/**
* @desc 创建日志队列的拉取任务,并生成检测器,单线程
* @auth kerry.wang
* @date 2021年4月16日
*/
public void createLogJob() {
checkQueue();
log.info("===>>> Log job started...");
while(true) {
while(!LOGQUEUE.isEmpty()) {
log.debug("===>>> detect queue logs, start to poll...");
synchronized (LOGLIST) {
LOGLIST.add(LOGQUEUE.poll());
if(LOGLIST.size() > BATCHSIZE) {
int rs = logDao.insertAccessLogBatch(LOGLIST);
lastLogTimeMillions = System.currentTimeMillis();
LOGLIST.clear();
log.debug("===>>> createLogJob 批量插入了 " + rs);
}
}
}
}
}
/**
* @desc 任务队列空闲检查,5s检查一次,空闲超过5秒,将剩余待插入log集合写入db
* @auth kerry.wang
* @date 2021年4月16日
*/
private void checkQueue() {
log.info("===>>> Queue check timer started...");
new Timer() {{
schedule(new TimerTask() {
@Override
public void run() {
log.debug("===>>> queue checking...");
// 如果日志插入线程空闲大于5s,则将队列中的剩余日志批量插入db
if(System.currentTimeMillis() - lastLogTimeMillions > FREETIME) {
// LOGLIST还要加一道锁,避免和Log job产生差异
synchronized (LOGLIST) {
if(!LOGLIST.isEmpty()) {
log.debug("===>>> detect remain logs, start to write...");
int rs = logDao.insertAccessLogBatch(LOGLIST);
LOGLIST.clear();
log.debug("===>>> checkQueue 批量插入了 " + rs);
}
}
}
}
}, 0, CHECKINTEVAL);
}};
}
}
塞入日志队列的异步线程池配置
package com.suitwin.gateway.config;
import java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* @desc 配置spring boot @async异步注解的线程池
* @auth kerry.wang
* @date 2021年1月22日
* spring boot 2.0默认线程池每开启一个新的任务都是新开一个线程的。2.2默认线程池有8个
*/
@Configuration
public class AsyncConfig {
/**
* @desc 为同步日志到队列创建最大16线程处理池
* @auth kerry.wang
* @date 2021年1月22日
* @return
*/
@Bean(name = "logExecutor")
public ThreadPoolTaskExecutor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(8);
// 线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
executor.setMaxPoolSize(16);
// 队列最大长度
executor.setQueueCapacity(5000);
// 允许的空闲时间,当超过了核心线程数之外的线程在空闲时间到达之后会被销毁
executor.setKeepAliveSeconds(100);
// 线程前缀
executor.setThreadNamePrefix("logExecutorThread-");
// 这里使用第4种,保证一旦出现超过并发的上限时,依旧能够再次重试,否则会导致最终业务逻辑或数据不达预期
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
}
启动类
package com.suitwin.gateway;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import com.suitwin.gateway.log.LogTask;
@EnableZuulProxy
//@SpringBootApplication// (exclude= {DruidDataSourceAutoConfigure.class, DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})
@SpringBootApplication(exclude= {DataSourceAutoConfiguration.class})
@EnableDiscoveryClient
@ComponentScan(basePackages = {"com.suitwin.common", "com.suitwin.gateway"})
@EnableAsync
public class SuitwinGatewayApplication implements CommandLineRunner {
@Autowired
private LogTask task;
public static void main(String[] args) {
SpringApplication.run(SuitwinGatewayApplication.class, args);
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", addcorsConfig());
return new CorsFilter(source);
}
private CorsConfiguration addcorsConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
List<String> list = new ArrayList<>();
list.add("*");
corsConfiguration.setAllowedOrigins(list);
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setMaxAge(168000l);
corsConfiguration.setAllowCredentials(true);
return corsConfiguration;
}
@Override
public void run(String... args) throws Exception {
System.err.println("===>>> Suitwin gateway started..." +Thread.currentThread().getName());
task.createLogJob();
}
}
下面是logstash的关键配置:
input {
jdbc {
jdbc_connection_string => "jdbc:mysql://192.168.0.234:3307/epc_cache?useUnicode=true&characterEncoding=UTF-8"
jdbc_driver_library => "D:/mysql-connector-java-5.1.35.jar"
jdbc_driver_class => "com.mysql.jdbc.Driver"
jdbc_user => "root"
jdbc_password => "root"
schedule => "* * * * *"
statement => "SELECT * FROM epc_cache.stw_gateway_log WHERE id > :sql_last_value"
use_column_value => true
tracking_column => "id"
last_run_metadata_path => "D:/logstash_log.txt"
clean_run => false
}
}
output {
elasticsearch {
hosts => ["http://192.168.0.234:9200"]
index => "suitwin-gateway-log"
document_type => "weblog"
document_id => "%{id}"
}
stdout {
codec => json_lines
}
}
以上一套下来,启动spring boot、logstash、es、mysql,即可实现日志保存到mysql和es
最后数据可视化:
打开kibana,先通过创建索引,实现目标日志表的基础查看
如果想要通过kibana实现后台日志的查看(而非登录服务器查看日志文件),选用上面介绍过的方案1即可,很方便。
ok,此时要进行分析了,可以通过filter过滤器,输入表达式,即可得到想要的结果,例如图中筛选了返回响应代码为106的日志。
那么如何构建图表,此时需要通过Visualize菜单进行创建。
上图展示了访问日志中,请求的汽车品牌的饼图统计。
ok,那么最终的各种树状图、折线图、饼图、热力图等等大屏数据展示如何完成?通过dashboard菜单。
通过添加上一步创建的Visualize组件(需要保存),即可将一个个单独的图表聚合到一个仪表板中进行显示,并且可以任意调节组件大小。
到此为止,整个流程就ok了,将其中的细节打磨一下,就能实现系统的所有日志处理,最终无需技术人员的介入,即可轻松完成平台访问情况的检测、统计、分析、可视化!
本方案的不足之处还请不吝赐教 – 来自一个什么都不会搞的产品经理