SpringBoot与Loki的那些事
生命不息,写作不止
持续踏上学习之路,学之分享笔记
总有一天我也能像各位大佬相同
一个有梦有戏的人 @盛开吧德德
分享学习心得,欢迎纠正,我们一同学习成长!
前语
由于网上很多都没有经过Loki的API自己完成对日志监控体系,所以我就下定决心自己出一版关于loki与springboot的博文供我们参考,这个可以说是比较有用,很合适中小型企业。因而我酝酿了挺久了,关于loki的研讨也比较久,期望各位读者能有新的收成。
简介
Loki是Grafana Labs团队的开源项目,可以组成一个功能齐全的日志堆栈。Loki是一个水平可扩展,高可用性,多租户的日志聚合体系。它的规划非常经济高效且易于操作,由于它不会为日志内容编制索引,而是为每个日志流编制一组标签。Loki是用来存储日志和处理查询,需求经过promtail来搜集日志,也但是经过后端的logback等日志结构来搜集日志,经过grafana供给的loki可视化检查日志,当然了loki也供给了API,可以依据自己的需求来自己完成可视化界面,可以削减三方插件的运用。
装置
上一篇文章已经介绍了怎么装置以及运用Grafana+loki+promtail进行建立日志体系,blog.csdn.net/qq_43843951…可以看看这篇文章。接下来笔者要介绍的是经过Loki的API编写自己可视化界面,而且经过logback来完成搜集日志。 大致的结构如图
简略介绍一下,首要便是经过springboot后端的logback日志结构来搜集日志,在推送到loki中存储,loki执行对日志的查询,经过API依据标签等信息去查询日志而且在自界说的前端界面中展示。
全体思路
其实宏观来看,要达成这个需求说起来是十分简略的,只需装备logback装备,在经过MDC写入、搜集日志,这儿可以很多的写法,可以是经过反射写入日志,也可以是在需求打印的地方写入日志,而且是将日志区分为不同的标签。在前端就可以依据所界说的标签来检查相应的日志。前端获取日志信息逻辑也很简略,就仅仅经过Loki供给的API获取每行的日志。接下来我就逐个详细的介绍SpringBoot与Loki的那些事。 可以检查此图便于了解:
Loki实战开发
接下来就详细解说笔者在实战开发中是怎么编写的,本次介绍仅仅对编写的代码进行详讲,关于代码或许不会悉数张贴,不然冗余起来作用不好,各位读者可以各自发挥,更加完善。其实整个事务也不难,根本都是loki本身供给的API,读者可以经过Loki官方网站grafana.com/docs/loki/l… 去进一步对Loki的API进行查阅,后面笔者或许也会出一篇来专门对Loki的API以及装备进行介绍。好了,废话不多说,马上进入正题。
springboot中的装备
首先需求装备向Loki推送日志,也便是需求经过Loki的API:POST /loki/api/v1/push ,可以直接将地址经过appender写死在logback日志结构中,但是在项目开发中,要考虑到环境的不同,应该是可以依据需求来修改loki服务器的地址,因而将loki的服务器地址装备在application-dev.yml中。
loki:
url: http://localhost:3100/loki/api/v1
装备logback日志结构
先获取yml装备的地址,经过appender增加到日志结构中,当然,装备客户端也不必定是LogBack结构,还有Log4j2结构也是可以运用的,详细装备可以看官网github.com/loki4j/loki… 和 github.com/tkowalcz/tj… ,本章只对loki进行解说,关于日志结构,后期也会逐个列出,各位读者有什么不了解的,可以先到网上查阅资料。由于笔者不是布置多台Loki服务器,不同的体系选用system这个标签来进行区分。
<springProperty scope="context" name="lokiUrl" source="loki.url"/>
<property name="LOKI_URL" value="${lokiUrl}"/>
<!--增加loki-->
<appender name="lokiAppender" class="com.github.loki4j.logback.Loki4jAppender">
<batchTimeoutMs>1000</batchTimeoutMs>
<http class="com.github.loki4j.logback.ApacheHttpSender">
<url>${LOKI_URL}/push</url>
</http>
<format>
<label>
<pattern>system=${SYSTEM_NAME},level=%level,logType=%X{log_file_type:-logType}</pattern>
</label>
<message>
<pattern>${log.pattern}</pattern>
</message>
<sortByTime>true</sortByTime>
</format>
</appender>
注解与切面写入日志
自界说注解,而且设置日志标签值。
/**
* @author: lyd
* @description: 自界说日志注解,用作LOKI日志分类
* @Date: 2022/10/10
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD})
@Documented
public @interface LokiLog {
LokiLogType type() default LokiLogType.DEFAULT;
}
经过枚举的方法来界说日志类型的标签值
/**
* @author: lyd
* @description: 枚举便签值 - 类型自己界说
* @Date: 2022/10/11
*/
public enum LokiLogType {
DEFAULT("默许"),
A("A"),
B("B"),
C("C");
private String desc;
LokiLogType(String desc) {
this.desc=desc;
}
public String getDesc() {
return desc;
}
}
编写切面,写入日志(详情可以参照这篇文章cloud.tencent.com/developer/a…),内部经过MDC.put(“log_file_type”, logType.getDesc());(MDC ( Mapped Diagnostic Contexts ),它是一个线程安全的寄存诊断日志的容器。可以参照:www.jianshu.com/p/1dea7479e…)可以了解为log_file_type是标签名,logType.getDesc()是标签值。
/**
* @author: lyd
* @description: 自界说日志切面:https://cloud.tencent.com/developer/article/1655923
* @Date: 2022/10/10
*/
@Aspect
@Slf4j
@Component
public class LokiLogAspect {
/**
* 切到一切OperatorLog注解修饰的办法
*/
@Pointcut("@annotation(org.nl.wms.log.LokiLog)")
public void operatorLog() {
// 空办法
}
/**
* 运用@Around环绕增强
*
* @return
*/
@Around("operatorLog()")
public synchronized Object around(ProceedingJoinPoint pjp) throws Throwable {
// ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// HttpServletRequest request = attributes.getRequest();
// HttpServletResponse response = attributes.getResponse();
Signature signature = pjp.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
LokiLog lokiLog = method.getAnnotation(LokiLog.class);
// 获取描绘信息
LokiLogType logType = lokiLog.type();
MDC.put("log_file_type", logType.getDesc());
log.info("输入参数:" + JSONObject.toJSONString(pjp.getArgs()));
Object proceed = pjp.proceed();
log.info("回来参数:" + JSONObject.toJSONString(proceed));
MDC.remove("log_file_type");
return proceed;
}
}
运用注解,在办法中引证注解即可
@LokiLog(type = LokiLogType.A)
前端界面与后端接口
前端界面介绍起来或许比较费事,毕竟写的代码也比较多,这儿就选取解说,代码量比较多,也不会是悉数代码张贴,样式之类的,我信任读者会依据自己的需求去完成,这儿首要的是记录开发的思路。
日志的开端获取
前端的界面就如图,本次是以el-admin这个为根底制作的demo。 查找日志是需求经过标签与标签值来获取日志信息,因而首先需求的是带着标签对到后端访问Loki的API拿到数据,读者可以查阅官网的API,结合着学习。 一开端当vue视图烘托的时分,就会从后端获取loki日志标签,详细后端接口的事务代码如下:
/**
* 获取labels和values树
*
* @return
*/
@Override
public JSONArray getLabelsValues() {
JSONArray result = new JSONArray();
// 获取一切标签
String labelString = HttpUtil.get(lokiUrl + "/labels", CharsetUtil.CHARSET_UTF_8);
JSONObject parse = (JSONObject) JSONObject.parse(labelString);
JSONArray labels = parse.getJSONArray("data");
for (int i=0; i<labels.size(); i++) {
// 获取标签下的一切值
String valueString = HttpUtil.get(lokiUrl + "/label/" + labels.getString(i) + "/values", CharsetUtil.CHARSET_UTF_8);
JSONObject parse2 = (JSONObject) JSONObject.parse(valueString);
JSONArray values = parse2.getJSONArray("data");
JSONArray children = new JSONArray();
// 组成树形状况 两级
for (int j=0; j<values.size(); j++) {
JSONObject leaf = new JSONObject();
leaf.put("label", values.getString(j));
leaf.put("value", values.getString(j));
children.add(leaf);
}
JSONObject node = new JSONObject();
node.put("label", labels.getString(i));
node.put("value", labels.getString(i));
node.put("children", children);
result.add(node);
}
return result;
}
中心代码就只有经过Hutool东西包去访问API获取标签HttpUtil.get(lokiUrl + “/labels”, CharsetUtil.CHARSET_UTF_8); 以及 获取标签值HttpUtil.get(lokiUrl + “/label/” + labels.getString(i) + “/values”, CharsetUtil.CHARSET_UTF_8); 由于我的前端是用elment-ui的树来接收的,因而我就将回来的数据规划成相应的形式。
<el-form-item label="日志标签">
<el-cascader
v-model="labelAndValue"
:options="labelsOptions"
placeholder="请挑选标签"
@change="queryData"
/>
</el-form-item>
模糊查找与更多参数
loki供给了相应的API来进行模糊查找日志,无非便是经过loki的API带着关键字进行模糊查找日志,笔者的做法是获取含有关键字的日志内容。
"/query_range?query={system=\"" + systemName + "\", " + logLabel + "=\"" + logLabelValue + "\"} |= `" + text + "`"
而且还可以经过时刻段来查询,笔者完成了的作用如图 不只可以经过关键字,还有时刻段时刻规模以及查找的方向和一次性显现的条数,最好是建议不要超过1000条数据,翻滚步数是完成翻滚下拉的时分获取新的日志数据的条目数。 后端代码如下,简略介绍一下,便是供给所需求的查询条件来对日志进行挑选。不管是获取日志数据仍是翻滚下拉获取的日志数据都可以通用这个接口,然而首要的参数设置可以在前端进行打磨,以下代码还有优化的空间,毕竟当时刚开端写的时分没考虑这么多。
@Override
public JSONObject getLogData(JSONObject json) {
String logLabel = "";
String logLabelValue = "";
Long start = 0L;
Long end = 0L;
String text = "";
String limit = "100";
String direction = "backward";
if (json.get("logLabel") != null) logLabel = json.getString("logLabel");
if (json.get("logLabelValue") != null) logLabelValue = json.getString("logLabelValue");
if (json.get("text") != null) text = json.getString("text");
if (json.get("start") != null) start = json.getLong("start");
if (json.get("end") != null) end = json.getLong("end");
if (json.get("limits") != null) limit = json.getString("limits");
if (json.get("direction") != null) direction = json.getString("direction");
/**
* 安排参数
* 纳秒数
* 1660037391880000000
* 1641453208415000000
* http://localhost:3100/loki/api/v1/query_range?query={host="localhost"} |= ``&limit=1500&start=1641453208415000000&end=1660027623419419002
*/
JSONObject parse = null;
String query = lokiUrl + "/query_range?query={system=\"" + systemName + "\", " + logLabel + "=\"" + logLabelValue + "\"} |= `" + text + "`";
String result = "";
if (start==0L) {
result = HttpUtil.get(query + "&limit=" + limit + "&direction=" + direction, CharsetUtil.CHARSET_UTF_8);
} else {
result = HttpUtil.get(query + "&limit=" + limit + "&start=" + start + "&end=" + end + "&direction=" + direction, CharsetUtil.CHARSET_UTF_8);
}
try {
parse = (JSONObject) JSONObject.parse(result);
} catch (Exception e) {
// reslut的值或许为:too many outstanding requests,无法转化成Json
System.out.println("reslut:" + result);
// e.printStackTrace();
}
return parse;
}
前端的逻辑是比较复杂的,由于需求做大量的赋值与设置。 前端js办法代码,首要是对参数数据的安排,这儿需求留意的是,由于loki需求的是纳秒级别的时刻戳,这儿就需求十分留意前端js的精度。还有一点便是,假如后端日志是有色彩标签的,那么前端直接烘托就会显现标签,所以这儿需求进行相应的处理,便是用过AnsiUp插件进行操作,详细看此篇文章:blog.csdn.net/qq_43843951…
queryData() {
console.log(this.labelAndValue)
// 清空查询数据
this.clearParam()
if (this.labelAndValue.length > 0) {
queryParam.logLabel = this.labelAndValue[0]
queryParam.logLabelValue = this.labelAndValue[1]
}
if (queryParam.logLabelValue === null) { // 判空
this.$message({
showClose: true,
message: '请挑选标签',
type: 'warning'
})
this.showEmpty = true
this.emptyText = '请挑选标签'
return
}
if (this.timeRange.length !== 0) { // 假如是输入时刻规模
queryParam.start = (new Date(this.timeRange[0]).getTime() * 1000000).toString()
queryParam.end = (new Date(this.timeRange[1]).getTime() * 1000000).toString()
}
if (this.timeZoneValue) {
const time = new Date()
queryParam.start = ((time.getTime() - this.timeZoneValue) * 1000000).toString()
queryParam.end = (time.getTime() * 1000000).toString()
}
if (this.text) {
queryParam.text = this.text.replace(/^\s*|\s*$/g, '') // 去空
}
if (this.limits) {
queryParam.limits = this.limits
}
queryParam.direction = this.direction
var ansi_up = new AnsiUp()
logOperation.getLogData(queryParam).then(res => {
this.showEmpty = false
if (res.data.result.length === 1) {
this.logs = res.data.result[0].values
for (const i in res.data.result[0].values) {
this.logs[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
}
} else if (res.data.result.length > 1) {
// 清空
this.logs = []
for (const j in res.data.result) { // 用push的方法将一切日志数组增加进去
for (const values_index in res.data.result[j].values) {
this.logs.push(res.data.result[j].values[values_index])
}
}
for (const k in this.logs) {
this.logs[k][1] = ansi_up.ansi_to_html(this.logs[k][1])
}
if (this.direction === 'backward') { // 由于运用公共标签会导致时刻次序紊乱,因而对二维数组进行排序
this.logs.sort((a, b) => b[0] - a[0])
} else {
this.logs.sort((a, b) => a[0] - b[0])
}
} else {
this.showEmpty = true
this.emptyText = '暂无日志信息,请挑选时刻段试试'
}
})
},
经过AnsiUp插件可以将带有色彩标签的日志以色彩展示,代码如下:
<div style="margin: 3px; min-height: 80vh;">
<!--数据判空-->
<el-empty v-if="showEmpty" :description="emptyText" />
<!--数据加载-->
<el-card v-else shadow="hover" style="width: 100%" class="log-warpper">
<div style="width: 100%">
<div v-for="(log, index) in logs" :key="index">
<div style="margin-bottom: 5px; font-size: 12px;" v-html="log[1]" />
</div>
</div>
</el-card>
</div>
向后端请求日志回来的结果是如下图所示
翻滚追加日志
其实下拉翻滚的代码与上面直接获取日志的是差不多的,仅仅在数据的追加是不相同的做法,这儿需求留意的是要考虑日志的展示是正序仍是逆序,不同的次序核算时刻规模是不相同的,就如下代码
if (this.direction === 'backward') { // 设置时刻区间
queryParam.start = (this.logs[this.logs.length - 1][0] - zone).toString()
queryParam.end = this.logs[this.logs.length - 1][0]
} else {
queryParam.start = this.logs[this.logs.length - 1][0]
queryParam.end = (parseFloat(this.logs[this.logs.length - 1][0]) + parseFloat(zone.toString())).toString()
}
在翻滚获取日志的思路是获取最终一条数据的时刻,往后推必定的时刻差,所以需求考虑是正序仍是倒序,默许是6小时。
mounted() {
window.addEventListener('scroll', this.handleScroll)
}
methods: {
handleScroll() { // 翻滚事情
const scrollTop = document.documentElement.scrollTop// 翻滚高度
const clientHeight = document.documentElement.clientHeight// 可视高度
const scrollHeight = document.documentElement.scrollHeight// 内容高度
const bottomest = Math.ceil(scrollTop + clientHeight)
if (bottomest >= scrollHeight) {
// 加载新数据
queryParam.limits = this.scrollStep
queryParam.direction = this.direction
// 获取时刻差
let zone = queryParam.end - queryParam.start
if (this.timeRange.length) { // 假如是输入时刻规模
zone = ((new Date(this.timeRange[1]).getTime() - new Date(this.timeRange[0]).getTime()) * 1000000).toString()
}
if (this.timeZoneValue) {
zone = this.timeZoneValue * 1000000
}
if (zone === 0) {
zone = 3600 * 1000 * 6
}
if (this.direction === 'backward') { // 设置时刻区间
queryParam.start = (this.logs[this.logs.length - 1][0] - zone).toString()
queryParam.end = this.logs[this.logs.length - 1][0]
} else {
queryParam.start = this.logs[this.logs.length - 1][0]
queryParam.end = (parseFloat(this.logs[this.logs.length - 1][0]) + parseFloat(zone.toString())).toString()
}
var ansi_up = new AnsiUp()
logOperation.getLogData(queryParam).then(res => {
console.log(res)
this.showEmpty = false
if (res.data.result.length === 1) {
// 假如回来的日志是相同的就不显现
if (res.data.result[0].values.length === 1 && ansi_up.ansi_to_html(res.data.result[0].values[0][1]) === this.logs[this.logs.length - 1][1]) {
this.$notify({
title: '警告',
duration: 1000,
message: '当时时刻段日志已最新!',
type: 'warning'
})
return
}
const log = res.data.result[0].values
for (const i in res.data.result[0].values) {
log[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
this.logs.push(log[i])
}
} else if (res.data.result.length > 1) {
const tempArray = [] // 数据需求处理,由所以追加数组,所以需求用额定变量来寄存
// 改写便是增加,不清空原数组
for (const j in res.data.result) { // 用push的方法将一切日志数组增加进去
for (const values_index in res.data.result[j].values) {
tempArray.push(res.data.result[j].values[values_index])
}
}
if (this.direction === 'backward') { // 由于运用公共标签会导致时刻次序紊乱,因而对二维数组进行排序
tempArray.sort((a, b) => b[0] - a[0])
} else {
tempArray.sort((a, b) => a[0] - b[0])
}
for (const k in tempArray) {
tempArray[k][1] = ansi_up.ansi_to_html(tempArray[k][1]) // 数据转化
this.logs.push(tempArray[k]) // 追加数据
}
} else {
this.$notify({
title: '警告',
duration: 1000,
message: '暂无以往日志数据!',
type: 'warning'
})
}
})
}
}
}
定时改写日志
当然,日志的获取也是需求实时改写的,这种不只可以运用定时器还可以运用websocket,笔者运用的是定时器,由于这个写起来比较简略。相关的代码以及解析如下: 视图
<el-form-item>
<el-dropdown split-button type="primary" size="mini" @click="queryData">
查询{{ runStatu }}
<el-dropdown-menu slot="dropdown">
<el-dropdown-item v-for="(item, index) in runStatuOptions" :key="index" @click.native="startInterval(item)">{{ item.label }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</el-form-item>
办法代码 代码大致也和上面两种状况是相似的,思路是获取当时时刻前(时刻差)的时刻到当时时刻的日志信息。这儿不需求管日志的时序方向,只需求做好一直时刻,留意纳秒级别,还有定时器不要忘记销毁。
startInterval(item) {
this.runStatu = item.label
console.log(item.value)
if (item.value !== 0) {
this.timer = setInterval(() => { // 定时改写
this.intervalLogs()
}, item.value)
} else {
console.log('销毁了')
clearInterval(this.timer)
}
},
intervalLogs() { // 定时器的办法
// 安排参数
// 设置开端时刻和结束时刻
// 开端为现在时刻
const start = new Date()
const end = new Date()
// 时差判别
let zone = queryParam.end - queryParam.start
if (this.timeRange.length) { // 假如是输入时刻规模
zone = ((new Date(this.timeRange[1]).getTime() - new Date(this.timeRange[0]).getTime()) * 1000000).toString()
}
if (this.timeZoneValue) {
zone = this.timeZoneValue * 1000000
}
if (zone === 0) { // 避免空指针
start.setTime(start.getTime() - 3600 * 1000 * 6)
queryParam.start = (start.getTime() * 1000000).toString()
} else {
queryParam.start = (start.getTime() * 1000000 - zone).toString()
}
queryParam.end = (end.getTime() * 1000000).toString()
queryParam.limits = this.limits
console.log('定时器最终参数:', queryParam)
var ansi_up = new AnsiUp() // 后端日志格式转化
logOperation.getLogData(queryParam).then(res => {
console.log('res', res)
this.showEmpty = false
debugger
if (res.data.result.length === 1) {
this.logs = res.data.result[0].values
for (const i in res.data.result[0].values) { // 格式转化
this.logs[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
}
} else if (res.data.result.length > 1) {
// 清空
this.logs = []
for (const j in res.data.result) { // 用push的方法将一切日志数组增加进去
for (const values_index in res.data.result[j].values) {
this.logs.push(res.data.result[j].values[values_index])
}
}
for (const k in this.logs) {
this.logs[k][1] = ansi_up.ansi_to_html(this.logs[k][1])
}
if (this.direction === 'backward') { // 由于运用公共标签会导致时刻次序紊乱,因而对二维数组进行排序
this.logs.sort((a, b) => b[0] - a[0])
} else {
this.logs.sort((a, b) => a[0] - b[0])
}
} else {
this.showEmpty = true
this.emptyText = '暂无日志信息,请挑选时刻段试试'
}
})
},
最终粘一小段展示的界面
总结
loki是轻量级的分布式日志查询结构,特别合适中小型企业,尤其是工业项目,在项目上线的时分可以经过这样的一个界面来观察日志,确实可以得到很大的协助,但是这个loki不是特别的稳定,最为常见的是会呈现ERP ERROR,这种错误是最头疼的,个人感觉或许是核算机或许网络的要素形成。
这篇文章出的比较不容易,期望读者详细看一下,假如有问题可以直接提出来,期望读者能学到新的常识,让我们一同学习!假如觉得还不错或许有用途的话,期望可以给我点个赞点个收藏,谢谢观看!
创造不易,如有错误请纠正,感谢观看!记得点赞哦!