2024年8月

继上次 博客园直播方向运营建议之后,我又冒出了一个小建议,
毕竟作为博客园精神股东,开动我脑洞,我也义不容辞责任
博客园的产品形式是博客文章, 拓展路径有两条,一条是 文章的分类,一条的是文章的内容
分类的控制权在博客园,可拓展性强
内容的控制权在文章作者,可获站性弱
所以更多的拓展方向在分类


软件行业生态
一线 厂商砸钱推广新技术
二级 厂商砸钱推广新技术做的新产品,进行销售和部署安装
阿里云普及之后,多数软件都可以在线安装,也就是的博客的文章里面完成安装进行poc
poc 是软件行业销售的一个重要环节,主要是体产品
软件工程师则是作为执行人,完成各个重要环节,也是博客园的主要用户



各种云厂商都有在线软件安装各种分成,这是块蛋糕
各种一线技术厂商也有新技术推广的预算,这是快蛋糕


如何吃下全球市场这两块蛋糕呢


1 博客分类调整新增各种新技术分类
2 博客分类调整新增各种新产品分类
发文章的时候可以将文章投递到具体的技术和产品类目
3 博客检索页面增加各种新产品和新技术分类对应的页面,尤其是新产品页面,可以收费做产品页面定制
4 博文中可以执行的代码是文章的核心资产,也是博客园数据中的核心资产


如果将可执行代码 作为独立产品开发,作为博客园盈利产品开发?


1 将可以独立执行代码作为一个文章插件进行开发
2  经过插件打包的独立代码,用户可以 1 选择链接到自己的服务器执行(代理售卖云服务器的蛋糕)
2 如果这段代码是在博客园注册产品,产品的在线安装、部署、poc 自动化之后,可以作为产品的营销线索,博客园参与营销分成


全球在线软件poc市场,是快巨大的蛋糕,吃下这块蛋糕,融资应该很轻松
通过一键执行,将云服务器卖给看文章的用户,毕竟文章里面的代码一旦可以执行验证代码对不对真的很方便,代理售卖服务器应该可以让博客园温饱

大家好,我是码农先森。

树挪死,人挪活,这个需求我做不了,换个人吧。大家都有过这种经历吧,放在编程语言身上就是 PHP 不行了,赶紧转 Go 语言吧。那转 Go 语言就真的行了?那可不见得,我个人认为这只是一种缓解焦虑转移注意力的方式罢了。有些朋友想尝试转 Go 语言或者正在路上了,在这个过程当中都会遇到思维转换的困境,这种困境往往就是编程习惯的路径依赖,总是会以 PHP 的编程思维去学习 Go 语言,这样的学习方式也造成了很多人在 Go 语言的学习道路戛然而止了。

困境

困境一词在大家的编程生涯中应该不止一次出现了吧,每天解决无数的 Bug 也就相当于无数次从困境的泥潭中脱出。那么这一次我想说的是 PHP 程序员转型 Go 语言的困境,结合我自己的认知我个人认为有这么几点。

首先在这个浮躁的社会面前,我们每天都面临着各种各样的问题,尤其是很多人总是唱衰 PHP 不行了,在这种情绪的传播下,有些人开始像无头苍蝇一样寻求转型的突破口,一会看看 Java 一会又看看 Go,结果呢一个也不能学有所成,这种急迫的转型心态往往都是以失败而告终。

其次传统的编程思维路径依赖,这个观点在刚开始也有提到,作为世界上最好的语言 PHP 上手简单编写效率高,实现的效果所见即所得,我们长期在一种编程模式下形成的习惯,以人性的角度来分析就是一旦形成相应的习惯便很难改变,我经常看到有些 PHP 程序员写的 Go 代码无时无刻都充满着一股 PHP 味,这便是路径依赖带来的结果。

最后据我了解大部分的 PHP 程序员都是半路杀出的程咬金基础知识薄弱,通过炼就了某某七天入门、三十天精通 PHP 的宝典教程,而误入了 PHP 的编程大家庭,在 PHP 的编程世界里自由的翱翔如鱼得水,成为了 PHP 界的一名 CURD 靓仔,然后满怀自信的去学习 Go 语言,结果由于基本功不扎实遇到 Go 语言中的指针、协程、通道、切片等特性就一脸懵逼,最终落荒而逃。

建议

既然有了困境,那么还是得想方法解决,才能走在转型 Go 语言的正确道路上。鲁迅先生曾说过「世界上本就没有路,走的人多了,自然就有路了」。

我们可以沿着别人走过的路走,也就是说我们可以寻找一名已经学有所成的 Go 语言小伙伴,跟随着他的脚步脚踏实地的学习,一步一步的模仿,模仿也是最好的学习方式之一。我们工作在一个内卷的职场,每天都浮躁于心,因此需要有一个确定的学习目标,不要东想一个西想一个,今天这个好学这个,明天那个好又跑去学那个,其实保持住一个稳定的目标和良好的心态,就已经成功了一大半,别像某些演员拍电视剧,永远都活不过第一集。

对于那些半路出家的 CURD 靓仔们,虽然你们现在过得潇洒自在,但还是建议多学习下计算机基础知识,比如操作系统、网络协议等,当你们那天面临着内部转型 Go 语言也能有所应对,因为我也听很多的朋友说自己公司都在尝试往 Go 语言方向转型,这也是一种趋势,雷军也曾说过「站在风口上的猪都能飞」,因此我们要顺势而为积极应对。

结语

在转型的困境下我们不应惧怕,但也不能盲目大意。学习的道路总是艰难而漫长的,因为学习本身就是逆人性的成长,要攻克人性并非易事。最后希望大家的转型之路能一切顺利,条条大路通罗马,每个人都是与众不同的个体,都有自己独特的学习之路,愿大家都能走在正确的道路上,一路长虹!本次分享内容就到这里了,希望对大家能有所启发。

感谢阅读,个人观点仅供参考,欢迎在评论区发表不同观点。


欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

下面我会将完整的代码放进去,一些样式使用的是全局样式如flex-row,flex-1,size-16,re,tc,等,不过不影响功能使用。

废话不多说,上代码

vue3+ts

<!-- 热区组件 -->
<template>
    <el-dialog v-model="dialog_visible" append-to-body fullscreen @close="close_event">
        <template #header>
            <div class="title re">
                <div class="tc size-16 fw">编辑热区</div>
            </div>
        </template>
        <el-scrollbar class="content-scrollbar">
            <div class="pa-40 flex-row gap-40">
                <div class="left-content flex-1 pa-20">
                    <el-scrollbar class="img-scrollbar">
                        <div class="img-container">
                            <div ref="imgBoxRef" @mousedown.prevent="start_drag" @mousemove.prevent="move_drag" @mouseup.prevent="end_drag">
                                <el-image :src="hot_list.img" class="w img" @selectstart.prevent @contextmenu.prevent @dragstart.prevent></el-image>
                                <div ref="areaRef" class="area" :style="init_drag_style"></div>
                                <div v-for="(item, index) in hot_list.hot" :key="index" class="area-box" :style="rect_style(item.drag_start, item.drag_end)" @mousedown.prevent="start_drag_area_box(index, $event)" @dblclick="dbl_drag_event(item, index)">
                                    <div class="del-btn" @click.stop="del_area_event(index)"><icon name="close"></icon></div>
                                    <div class="drag-btn" :data-index="index" @mousedown.prevent="start_drag_btn(index, $event)"></div>
                                    <div class="text">
                                        <div class="name">{{ item.name }}</div>
                                        <div class="status" :class="item.status ? 'cr-primary' : 'cr-error'">{{ item.status ? '已设置' : '未设置' }}</div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </el-scrollbar>
                </div>
                <div class="right-content flex-1 pa-20">
                    <div class="size-16 fw mb-10">图片热区</div>
                    <div class="size-12 cr-9 mb-20">框选热区范围,双击设置热区信息</div>
                    <div class="flex-col gap-20 item">
                        <div v-for="(item, index) in hot_list.hot" :key="index" class="flex-row align-c gap-10">
                            <el-input v-model="item.name" class="name" placeholder="名称"></el-input>
                            <url-value v-model="item.link"></url-value>
                            <icon name="del" size="20" @click="del_event(index)"></icon>
                        </div>
                    </div>
                </div>
            </div>
        </el-scrollbar>
        <template #footer>
            <span class="dialog-footer">
                <el-button class="plr-28 ptb-10" type="primary" @click="confirm_event">完成</el-button>
            </span>
        </template>
    </el-dialog>
    <el-dialog v-model="hot_dialog_visible" width="560" append-to-body @close="hot_close_event">
        <template #header>
            <div class="title re">
                <div class="tc size-16 fw">设置热区</div>
            </div>
        </template>
        <div class="content">
            <el-form ref="formRef" :model="form" label-width="85px" class="pa-20 mt-16">
                <el-form-item label="热区跳转链接">
                    <url-value v-model="form.link"></url-value>
                </el-form-item>
            </el-form>
        </div>
        <template #footer>
            <span class="dialog-footer">
                <el-button class="plr-28 ptb-10" @click="hot_close_event">取消</el-button>
                <el-button class="plr-28 ptb-10" type="primary" @click="hot_confirm_event">确定</el-button>
            </span>
        </template>
    </el-dialog>
    <el-button class="w" @click="dialog_visible = true"><icon name="add">编辑热区</icon></el-button>
</template>
<script lang="ts" setup>import { cloneDeep }from 'lodash';const app =getCurrentInstance();/**
* @description: 热区
* @param modelValue{Object} 默认值
* @param dialog_visible {Boolean} 弹窗显示
* @return {*} update:modelValue
*/ const props =defineProps({});const modelValue = defineModel({ type: Object as PropType<hotData>, default: {} });const dialog_visible = defineModel('visibleDialog', { type: Boolean, default: false});const hot_list = ref<hotData>({
img:
'',
hot: [],
});
const hot_list_index = ref(0);
watch(
()
=>modelValue.value,
(val)
=>{
hot_list.value
=cloneDeep(val);
console.log(val);
},
{ immediate:
true, deep: true}
);
//#region 左侧画布-----------------------------------------------start const imgBoxRef = ref<HTMLElement | null>(null);const rect_start = ref<rectCoords>({ x: 0, y: 0, width: 0, height: 0});const rect_end = ref<rectCoords>({ x: 0, y: 0, width: 0, height: 0});const areaRef = ref<HTMLElement | null>(null);const init_drag_style = ref('');const drag_bool = ref(false);const drag_box_bool = ref(false);const drag_box_scale_bool = ref(false);const start_drag = (event: MouseEvent) =>{
drag_bool.value
= true;if (!imgBoxRef.value) return;
rect_start.value.x
= event.clientX -imgBoxRef.value.getBoundingClientRect().left;
rect_start.value.y
= event.clientY -imgBoxRef.value.getBoundingClientRect().top;
rect_start.value.width
= 0;
rect_start.value.height
= 0;
};
const move_drag = (event: MouseEvent) =>{if(drag_bool.value) {if (!imgBoxRef.value) return;
rect_end.value.x
= event.clientX -imgBoxRef.value.getBoundingClientRect().left;
rect_end.value.y
= event.clientY -imgBoxRef.value.getBoundingClientRect().top;
rect_end.value.width
= rect_end.value.x - rect_start.value.x > 0 ? rect_end.value.x - rect_start.value.x : 0;
rect_end.value.height
= rect_end.value.y - rect_start.value.y > 0 ? rect_end.value.y - rect_start.value.y : 0;
init_drag_style.value
= `left: ${rect_start.value.x}px;top: ${rect_start.value.y}px;width: ${Math.max(rect_end.value.width, 1)}px;height: ${Math.max(rect_end.value.height, 1)}px;display: flex;`;
}
};
const end_drag = (event: MouseEvent) =>{
drag_bool.value
= false;if (areaRef.value) areaRef.value.style.display = 'none';if (!imgBoxRef.value) return;
init_drag_style.value
=``;if (rect_end.value.width > 16 && rect_end.value.height > 16) {
hot_list.value.hot.push({
name:
'热区' + (hot_list.value.hot.length + 1),
link: {},
drag_start: cloneDeep(rect_start.value),
drag_end: cloneDeep(rect_end.value),
status:
false,
});
}
rect_end.value
= { x: 0, y: 0, width: 0, height: 0};
};
const area_box_point = ref({ x: 0, y: 0});//area-box const dbl_drag_event = (item: hotListData, index: number) =>{
hot_dialog_visible.value
= true;
form.value.link
=item.link;
hot_list_index.value
=index;
};
const start_drag_area_box = (index: number, event: MouseEvent) =>{
hot_list_index.value
=index;event.stopPropagation();
drag_box_bool.value
= true;
let clone_drag_start
=cloneDeep(hot_list.value.hot[hot_list_index.value].drag_start);
let clone_drag_end
=cloneDeep(hot_list.value.hot[hot_list_index.value].drag_end);//记录原始位置 area_box_point.value ={
x: clone_drag_start.x
- event.clientX,
y: clone_drag_start.y
- event.clientY,
};
//当子元素拖拽方法触发后夫元素方法不触发 document.onmousemove = (areaBoxEvent) =>{
areaBoxEvent.stopPropagation();
if(drag_box_bool.value) {if (!imgBoxRef.value) return;const new_coordinate ={
x: areaBoxEvent.clientX
+area_box_point.value.x,
y: areaBoxEvent.clientY
+area_box_point.value.y,
};
//左上边界判断 if (new_coordinate.x < 0) {
new_coordinate.x
= 0;
}
if (new_coordinate.y < 0) {
new_coordinate.y
= 0;
}
//右下边界判断 if (new_coordinate.x + Math.max(clone_drag_end.width, 1) >imgBoxRef.value.getBoundingClientRect().width) {
new_coordinate.x
= imgBoxRef.value.getBoundingClientRect().width - Math.max(clone_drag_end.width, 1);
}
if (new_coordinate.y + Math.max(clone_drag_end.height, 1) >imgBoxRef.value.getBoundingClientRect().height) {
new_coordinate.y
= imgBoxRef.value.getBoundingClientRect().height - Math.max(clone_drag_end.height, 1);
}
hot_list.value.hot[hot_list_index.value].drag_start.x
=new_coordinate.x;
hot_list.value.hot[hot_list_index.value].drag_start.y
=new_coordinate.y;
}
};
document.onmouseup
= (areaBoxEvent) =>{
areaBoxEvent.stopPropagation();
drag_box_bool.value
= false;
};
};
//drag-btn const start_drag_btn = (index: number, event: MouseEvent) =>{
hot_list_index.value
=index;event.stopPropagation();
drag_box_scale_bool.value
= true;
let clone_drag_start
=hot_list.value.hot[hot_list_index.value].drag_start;
let clone_drag_end
=hot_list.value.hot[hot_list_index.value].drag_end;
document.onmousemove
= (dragBtnEvent) =>{
dragBtnEvent.stopPropagation();
//用鼠标的位置减去鼠标相对元素的位置,得到元素的位置 if(drag_box_scale_bool.value) {if (!imgBoxRef.value) return;
clone_drag_end.x
= dragBtnEvent.clientX -imgBoxRef.value.getBoundingClientRect().left;
clone_drag_end.y
= dragBtnEvent.clientY -imgBoxRef.value.getBoundingClientRect().top;
hot_list.value.hot[hot_list_index.value].drag_end
={
x: clone_drag_end.x,
y: clone_drag_end.y,
width: clone_drag_end.x
- clone_drag_start.x > 0 ? clone_drag_end.x - clone_drag_start.x : 0,
height: clone_drag_end.y
- clone_drag_start.y > 0 ? clone_drag_end.y - clone_drag_start.y : 0,
};
}
};
document.onmouseup
= (dragBtnEvent2) =>{
dragBtnEvent2.stopPropagation();
drag_box_scale_bool.value
= false;
};
};
const del_area_event = (index: number) =>{
hot_list.value.hot.splice(index,
1);
};
const rect_style = computed(() =>{return (start: rectCoords, end: rectCoords) =>{return `left: ${start.x}px;top: ${start.y}px;width: ${Math.max(end.width, 1)}px;height: ${Math.max(end.height, 1)}px;display: flex;`;
};
});
//#endregion 左侧画布-----------------------------------------------end//#region 右侧热区编辑-----------------------------------------------start const del_event = (index: number) =>{
hot_list.value.hot.splice(index,
1);
};
//#endregion 右侧热区编辑-----------------------------------------------end//#region 设置热区弹窗-----------------------------------------------start const hot_dialog_visible = ref(false);const form = ref({
link: {},
});
const hot_close_event = () =>{
hot_dialog_visible.value
= false;
};
const hot_confirm_event = () =>{
hot_list.value.hot[hot_list_index.value].link
=form.value.link;
hot_close_event();
};
//#endregion 设置热区弹窗-----------------------------------------------end//#region 热区确认取消回调 -----------------------------------------------start//取消回调 const close_event = () =>{
dialog_visible.value
= false;
};
//确认回调 const confirm_event = () =>{
modelValue.value
=hot_list.value;
close_event();
};
//#endregion 热区确认取消回调 -----------------------------------------------end </script> <style lang="scss" scoped>.content-scrollbar {
height: calc(100vh
- 13.8rem);
margin:
0 -1.6rem;
.left
-content {
.img
-scrollbar {
display: flex;
justify
-content: center;
.img
-container {
max
-width: 60rem;
min
-width: 30rem;
height: calc(100vh
- 25.8rem);
position: relative;
.img {
user
-select: none;
cursor: crosshair;
}
.area {
position: absolute;
background: rgba(
41, 128, 185, 0.3);
border: 1px dashed #34495e;
width: 0px;
height: 0px;
left: 0px;
top: 0px;
display: none;
}
.area
-box {
position: absolute;
background: rgba(
42, 148, 255, 0.25);
border: 1px dashed #8ec6ff;
display: flex;
justify
-content: center;
align
-items: center;
color: #1989fa;
font
-size: 1.2rem;
cursor: move;
transition: transform
0.1s;
.del
-btn {
display: flex;
justify
-content: center;
align
-items: center;
background: #1890ff;
color: #fff;
text
-align: center;
border
-radius: 0 0 0 0.3rem;
position: absolute;
right:
0.7rem;
top:
0.7rem;
transform: translate3d(
50%, -50%, 0);
cursor:
default;
width:
1.6rem;
height:
1.6rem;
line
-height: 1.6rem;
z
-index: 1;
i {
font
-size: 0.9rem;
}
}
.drag
-btn {
position: absolute;
width: 7px;
height: 7px;
background: transparent;
right:
0;
bottom:
0;
transform: translate3d(
50%, 50%, 0);
cursor: nwse
-resize;
z
-index: 1;
}
.text {
overflow: hidden;
display: flex;
flex
-wrap: wrap;
justify
-content: center;
max
-width: 100%;
max
-height: 100%;
text
-align: center;
align
-items: center;
color: #fff;
font
-size: 1.2rem;
.name {
color: #fff;
margin:
0 0.2rem;
}
.status {
margin:
0 0.2rem;
}
}
}
}
}
}
.right
-content {
.item {
max
-width: 47.8rem;
.name {
width:
9.8rem;
}
}
}
}
</style>

vue3+js写法

<!-- 上传组件 -->
<template>
    <el-dialog v-model="dialogVisible" width="1168" append-to-body fullscreen @close="close_event">
        <template #header>
            <div class="title re">
                <div class="tc size-16 fw">编辑热区</div>
            </div>
        </template>
        <el-scrollbar class="content-scrollbar">
            <div class="pa-40 flex-row gap-40">
                <div class="left-content flex-1 pa-20">
                    <el-scrollbar class="img-scrollbar">
                        <div class="img-container">
                            <div ref="imgBoxRef" @mousedown.prevent="start_drag" @mousemove.prevent="move_drag" @mouseup.prevent="end_drag">
                                <el-image :src="modelValue.img" class="w img" @selectstart.prevent @contextmenu.prevent @dragstart.prevent></el-image>
                                <div ref="areaRef" class="area" :style="init_drag_style"></div>
                            </div>
                        </div>
                    </el-scrollbar>
                </div>
                <div class="right-content flex-1 pa-20">
                    <div class="size-16 fw mb-10">图片热区</div>
                    <div class="size-12 cr-9 mb-20">框选热区范围,双击设置热区信息</div>
                    <div class="flex-col gap-20 item">
                        <div v-for="(item, index) in modelValue.hot" :key="index" class="flex-row align-c gap-10">
                            <el-input v-model="item.name" class="name" placeholder="名称"></el-input>
                            <url-value v-model="item.link"></url-value>
                            <icon name="del" size="20" @click="del_event(index)"></icon>
                        </div>
                    </div>
                </div>
            </div>
        </el-scrollbar>
        <template #footer>
            <span class="dialog-footer">
                <el-button class="plr-28 ptb-10" type="primary" @click="confirm_event">完成</el-button>
            </span>
        </template>
    </el-dialog>
    <el-button class="w" @click="dialogVisible = true"><icon name="add">编辑热区</icon></el-button>
</template>
<script lang="ts" setup>import { cloneDeep }from 'lodash';const app =getCurrentInstance();/**
* @description: 热区
* @param modelValue{Object} 默认值
* @param dialogVisible {Boolean} 弹窗显示
* @param type{String} 链接类型为空数组则表示无限制,全部可用,传过来则表示传的值可用
* @param placeholder{String} 提示文字
* @return {*} update:modelValue
*/ const props =defineProps({});const modelValue = defineModel({ type: Object as PropType<hotData>, default: {} });const dialogVisible = defineModel('visibleDialog', { type: Boolean, default: false});//#region 左侧画布-----------------------------------------------start interfaceRectCoords {
x: number;
y: number;
width: number;
height: number;
}
interfacedrag {
status: boolean;
name:
string;
link:
object;
}
const drag_list = ref<drag[]>([]);const imgBoxRef = ref<HTMLElement | null>(null);//开始坐标 const rect_start = ref<RectCoords>({ x: 0, y: 0, width: 0, height: 0});//结束坐标 const rect_end = ref<RectCoords>({ x: 0, y: 0, width: 0, height: 0});//拖拽显示的占位区域 const areaRef = ref<HTMLElement | null>(null);//拖拽显示的初始化样式 const init_drag_style = ref('');//拖拽开关 const drag_bool = ref(false);//拖拽box开关 const drag_box_bool = ref(false);//拖拽box放大缩小开关 const drag_box_scale_bool = ref(false);//拖拽鼠标左击 开始 const start_drag = (event: MouseEvent) =>{
drag_bool.value
= true;if (!imgBoxRef.value) return;
rect_start.value.x
= event.clientX -imgBoxRef.value.getBoundingClientRect().left;
rect_start.value.y
= event.clientY -imgBoxRef.value.getBoundingClientRect().top;
rect_start.value.width
= 0;
rect_start.value.height
= 0;
};
//跟随鼠标移动 const move_drag = (event: MouseEvent) =>{if(drag_bool.value) {if (!imgBoxRef.value) return;
rect_end.value.x
= event.clientX -imgBoxRef.value.getBoundingClientRect().left;
rect_end.value.y
= event.clientY -imgBoxRef.value.getBoundingClientRect().top;
rect_end.value.width
= rect_end.value.x - rect_start.value.x > 0 ? rect_end.value.x - rect_start.value.x : 0;
rect_end.value.height
= rect_end.value.y - rect_start.value.y > 0 ? rect_end.value.y - rect_start.value.y : 0;
init_drag_style.value
=rect_style(rect_start.value, rect_end.value);
}
};
//拖拽结束 const end_drag = (event: MouseEvent) =>{
drag_bool.value
= false;if (areaRef.value) areaRef.value.style.display = 'none';if (!imgBoxRef.value) return;
let clone_drag_start
=cloneDeep(rect_start.value);
let clone_drag_end
=cloneDeep(rect_end.value);
init_drag_style.value
=``;if (rect_end.value.width > 16 && rect_end.value.height > 16) {//克隆area元素,并将class=“area”改为area-box const area_box = document.createElement('div');
area_box.className
= 'area-box';
area_box.style.cssText
=rect_style(clone_drag_start, clone_drag_end);if (areaRef.value) areaRef.value.parentNode?.appendChild(area_box);
drag_list.value.push({
name:
'热区' + drag_list.value.length + 1,
link: {},
status:
false,
});
//area_box 添加拖拽功能 area_box.onmousedown = (areaEvent) =>{
areaEvent.stopPropagation();
drag_box_bool.value
= true;//记录原始位置 let area_box_point ={
x: clone_drag_start.x
-areaEvent.clientX,
y: clone_drag_start.y
-areaEvent.clientY,
};
//当子元素拖拽方法触发后夫元素方法不触发 document.onmousemove = (areaBoxEvent) =>{
areaBoxEvent.stopPropagation();
if(drag_box_bool.value) {
console.log(
'area_box onmousemove');if (!imgBoxRef.value) return;const new_coordinate ={
x: areaBoxEvent.clientX
+area_box_point.x,
y: areaBoxEvent.clientY
+area_box_point.y,
};
//左上边界判断 if (new_coordinate.x < 0) {
new_coordinate.x
= 0;
}
if (new_coordinate.y < 0) {
new_coordinate.y
= 0;
}
//右下边界判断 if (new_coordinate.x + Math.max(clone_drag_end.width, 1) >imgBoxRef.value.getBoundingClientRect().width) {
new_coordinate.x
= imgBoxRef.value.getBoundingClientRect().width - Math.max(clone_drag_end.width, 1);
}
if (new_coordinate.y + Math.max(clone_drag_end.height, 1) >imgBoxRef.value.getBoundingClientRect().height) {
new_coordinate.y
= imgBoxRef.value.getBoundingClientRect().height - Math.max(clone_drag_end.height, 1);
}
clone_drag_start.x
=new_coordinate.x;
clone_drag_start.y
=new_coordinate.y;
area_box.style.cssText
=rect_style(clone_drag_start, clone_drag_end);
}
};
document.onmouseup
= (areaBoxEvent) =>{
areaBoxEvent.stopPropagation();
drag_box_bool.value
= false;
};
};
//在新增的元素内创建删除按钮 --------------- const del_btn = document.createElement('div');
del_btn.className
= 'del-btn';
del_btn.innerHTML
= `<i class="iconfont icon-close"></i>`;
area_box.appendChild(del_btn);
del_btn.onclick
= () =>{event.stopPropagation();//将当前点击的del_btn的父级area_box完全删除不留痕迹 area_box.parentNode?.removeChild(area_box);
};
//在新增的元素内创建拖拽按钮 --------------- const drag_btn = document.createElement('div');
drag_btn.className
= 'drag-btn';
area_box.appendChild(drag_btn);
//当子元素拖拽方法触发后夫元素方法不触发 drag_btn.onmousedown = (dragBtnEvent) =>{
dragBtnEvent.stopPropagation();
drag_box_scale_bool.value
= true;// //使用document绑定的drag事件:如果绑定到元素本身的情况下,鼠标拖动过快,鼠标会离开拖拽的元素,导致拖拽一段距离,拖拽失效的问题 document.onmousemove = (dragBtnEvent2) =>{
dragBtnEvent2.stopPropagation();
//用鼠标的位置减去鼠标相对元素的位置,得到元素的位置 if(drag_box_scale_bool.value) {if (!imgBoxRef.value) return;
clone_drag_end.x
= dragBtnEvent2.clientX -imgBoxRef.value.getBoundingClientRect().left;
clone_drag_end.y
= dragBtnEvent2.clientY -imgBoxRef.value.getBoundingClientRect().top;
clone_drag_end.width
= clone_drag_end.x - clone_drag_start.x > 0 ? clone_drag_end.x - clone_drag_start.x : 0;
clone_drag_end.height
= clone_drag_end.y - clone_drag_start.y > 0 ? clone_drag_end.y - clone_drag_start.y : 0;
area_box.style.cssText
=rect_style(clone_drag_start, clone_drag_end);
}
};
document.onmouseup
= (dragBtnEvent3) =>{
dragBtnEvent3.stopPropagation();
drag_box_scale_bool.value
= false;
};
};
//在新增的元素内创建文本提示--------------- const drag_text = document.createElement('div');
drag_text.className
= 'text';
drag_text.innerHTML
= `<div class="name">热区一</div><div class="status">未设置</div>`;
area_box.appendChild(drag_text);
}
rect_end.value
= { x: 0, y: 0, width: 0, height: 0};
};
const rect_style = (start: RectCoords, end: RectCoords) =>{return `left: ${start.x}px;top: ${start.y}px;width: ${Math.max(end.width, 1)}px;height: ${Math.max(end.height, 1)}px;display: flex;`;
};
//#region 左侧画布-----------------------------------------------end//#region 右侧热区编辑-----------------------------------------------start const del_event = (index: number) =>{
modelValue.value.hot.splice(index,
1);
};
//#region 右侧热区编辑-----------------------------------------------end//#region 热区确认取消回调 -----------------------------------------------start//取消回调 const close_event = () =>{
dialogVisible.value
= false;
};
//确认回调 const confirm_event = () =>{
close_event();
};
//#endregion 热区确认取消回调 -----------------------------------------------end </script> <style lang="scss" scoped>.content-scrollbar {
height: calc(100vh
- 13.8rem);
margin:
0 -1.6rem;
.left
-content {
.img
-scrollbar {
display: flex;
justify
-content: center;
.img
-container {
max
-width: 60rem;
min
-width: 30rem;
height: calc(100vh
- 25.8rem);
position: relative;
.img {
user
-select: none;
cursor: crosshair;
}
.area {
position: absolute;
background: rgba(
41, 128, 185, 0.3);
border: 1px dashed #34495e;
width: 0px;
height: 0px;
left: 0px;
top: 0px;
display: none;
}
:deep(.area
-box) {
position: absolute;
background: rgba(
42, 148, 255, 0.25);
border: 1px dashed #8ec6ff;
display: flex;
justify
-content: center;
align
-items: center;
color: #1989fa;
font
-size: 1.2rem;
cursor: move;
transition: transform
0.1s;
.del
-btn {
display: flex;
justify
-content: center;
align
-items: center;
background: #1890ff;
color: #fff;
text
-align: center;
border
-radius: 0 0 0 0.3rem;
position: absolute;
right:
0.7rem;
top:
0.7rem;
transform: translate3d(
50%, -50%, 0);
cursor:
default;
width:
1.6rem;
height:
1.6rem;
line
-height: 1.6rem;
z
-index: 1;
i {
font
-size: 0.9rem;
}
}
.drag
-btn {
position: absolute;
width: 7px;
height: 7px;
background: transparent;
right:
0;
bottom:
0;
transform: translate3d(
50%, 50%, 0);
cursor: nwse
-resize;
z
-index: 1;
}
.text {
overflow: hidden;
display: flex;
flex
-wrap: wrap;
justify
-content: center;
max
-width: 100%;
max
-height: 100%;
text
-align: center;
align
-items: center;
color: #fff;
font
-size: 1.2rem;
.name,
.status {
color: #fff;
margin:
02px;
}
}
}
}
}
}
.right
-content {
.item {
max
-width: 47.8rem;
.name {
width:
9.8rem;
}
}
}
}
</style>

预览效果:

image

引言

作为一款高性能的 OLAP 数据库,ClickHouse 被用于多种应用场景,包括
时间序列(time series)
数据的实时分析。其多样化的应用场景推动了大量
分析函数
的发展,这些函数有助于查询大多数类型的数据。这些查询特性加上高压缩率使得越来越多的用户开始利用 ClickHouse 来存储可观测性数据。这类数据通常以三种常见形式存在:
日志(logs)

指标(metrics)

跟踪(traces)
记录。在这系列的博客文章中,我们将探讨如何收集、最优地存储、查询以及可视化这些
“支柱(pillars)”

在本篇文章中,我们从日志开始,探讨日志的收集与查询的可能性。我们尽量确保提供的示例是可以复现的。同时需要注意的是,ClickHouse 对于特定数据类型的代理支持一直在不断进化,本文代表了截至
2023 年 1 月
的生态系统现状。因此,我们总是建议用户查看最新的文档和相关问题。

虽然我们的示例假设了一个现代架构,即用户需要从 Kubernetes 集群中收集日志,但我们的建议和指导并不依赖于Kubernetes,同样适用于自管理服务器或其他容器编排系统。我们在开发云环境中进行了测试,大约
20
个节点每天产生约
100GB
的日志。请注意,我们没有对代理进行调优,也没有测量它们的资源开销——这是我们在生产部署前推荐用户研究或执行的任务。本文主要关注数据收集,提出了一个
数据模型(data model)

模式(schema)
设计,而优化方面留待后续的文章讨论。

为了演示,我们将数据存储在一个
ClickHouse Cloud
服务中,在这里你可以几分钟内启动一个免费试用集群,无需担心基础设施的问题,即可开始查询!

注意:本文中所有可复现的配置示例都可以在这个
仓库
中查阅。

架构

大多数代理采用一种通用的架构模式来大规模收集可观测性数据,推广了
代理(agent)

聚合器(aggregator)
的概念。对于小型部署来说,可以忽略聚合器的概念,代理部署在其数据源附近,负责处理并直接通过 HTTP 或原生协议将数据发送到 ClickHouse。在 Kubernetes 环境中,这意味着将代理作为
Daemonset
部署。这会在每个 k8s 节点上部署一个
agent pod
,负责收集其他容器的日志(通常是从磁盘读取)。
image

这种架构对于不需要高持久性和可用性的用户以及少量代理且配置更改成本低的情况是足够的。然而,用户应该意识到这可能会导致许多小批量插入,特别是如果代理被配置为频繁刷新数据的话,例如因为需要数据能够及时提供以供分析和问题识别。在这种情况下,用户应考虑配置代理使用
异步插入
,以避免因
过多的分片
而导致的常见问题。

大型部署引入了
聚合器(aggregator)

网关(gateway)
的概念。这一概念旨在配置轻量级的代理靠近其数据源,仅负责将数据转发给聚合器。这样减少了干扰现有服务的可能性。聚合器负责处理步骤,如
丰富数据(enrichment)

过滤(filtering)
、确保应用了
模式(schema)
,以及批处理和可靠地将数据传送到 ClickHouse。聚合器通常作为
Deployment

Statefulset
部署,并可以根据需要创建多个副本以实现高可用性。

image

除了减少数据源潜在关键服务上的负载外,这种架构还允许数据以更大的块的形式批处理并插入到 ClickHouse 中。这一特性非常重要,因为它符合
ClickHouse 的最佳插入实践

上述架构简化了企业架构,实际上还需要考虑数据缓冲的位置、负载均衡、高可用性、复杂路由以及记录(归档)系统和分析系统的分离。这些概念在 Vector 文档中有详细的
介绍
。虽然这些内容是针对 Vector 的,但其中的原则同样适用于其他讨论过的代理。这些架构的一个重要特点是代理和聚合器也可以是异构的,
混合使用不同的技术是很常见的
,尤其是在收集不同类型的数据时,因为某些代理在不同的可观测性支柱方面表现出色。

Agents(代理)

在 ClickHouse,我们的用户倾向于使用四种主要的代理技术:Open Telemetry Collector、Vector、FluentBit 和 Fluentd。后两者具有相同的起源和很多相同的概念。为了简洁起见,我们探讨 FluentBit,它更轻量级且足以满足Kubernetes 中的日志收集需求,但使用 Fluentd 也是一种有效的方法。这些代理可以承担聚合器(aggregator)或收集器(collector)的角色,并且可以
在一定程度上一起使用
。尽管它们使用的术语不同,但都采用了插件式的输入(inputs)、过滤器(filters)/处理器(processors)和输出(outputs)的通用架构。ClickHouse 要么作为官方输出得到支持,要么通过通用 HTTP 支持实现集成。

然而,在下面的初始示例中,我们将每个代理(agent)都部署为聚合器(aggregator)和收集器(collector)角色。我们使用每个代理的官方 Helm chart 来获得简单的入门体验,记录重要的配置更改,并分享
values.yaml
文件。

我们的示例使用单个副本(replica)作为聚合器(aggregator),尽管这些聚合器可以很容易地部署多个副本并通过负载均衡提高性能和容错能力。所有的代理都支持使用 Kubernetes 元数据丰富日志,这对于未来的分析至关重要,例如 pod 名称、容器 id 和日志来源的节点。日志条目还可以包含
注解(Annotations)

标签(labels)
(FluentBit 和 Vector 默认启用)。这些通常是稀疏的,但可能数量众多(数百个);生产架构应该评估它们的价值并对其进行过滤。我们建议使用 Map 类型来存储这些元数据,以避免列爆炸问题,
这对查询有影响

所有代理在作为聚合器部署时都需要通过
resources
YAML 键进行调优,以避免内存溢出(OOM)问题,并跟上我们的吞吐量(每天大约
100GB
)。根据聚合器的数量和日志吞吐量的不同,您的情况可能会有所不同,但在大型环境中几乎总是需要调整资源。

Open Telemetry (OTEL) Collector (alpha)

OpenTelemetry 是一套工具、API 和 SDK 的集合,用于仪器化(instrumenting)、生成、收集和导出可观测性数据。除了在大多数流行的编程语言中提供代理之外,还有一个使用 Golang 编写的
Collector 组件
,提供了接收、处理和导出可观测性数据的供应商无关实现。通过支持多种输入格式,如 Prometheus 和 OTLP,以及广泛的导出目标,包括 ClickHouse,OTEL Collector 可以提供一个集中的处理网关。Collector 使用
receiver(接收器)

processor(处理器)

exporter(导出器)
这三个术语来表示其三个阶段,并使用
gateway(网关)
来指代聚合器实例。

虽然更常被用作网关/聚合器,处理诸如批处理和重试等任务,但 Collector 也可以作为
代理本身
部署。
OTLP
代表了
Open Telemetry 的数据标准
,用于网关和代理实例之间的通信,可以通过 gRPC 或 HTTP 发生。正如我们将在下面看到的,这种协议也得到了 Vector 和 FluentBit 的支持。

ClickHouse 支持

ClickHouse 在 OTEL 导出器中通过
社区贡献
得到了支持,支持日志和跟踪(对于指标的支持正在审查一个
PR
)。与 ClickHouse 的通信通过优化的原生格式和协议,使用官方 Go 客户端进行。

在使用 Open Telemetry Collector 之前,用户应考虑以下几点:

  • 代理使用的 ClickHouse 数据模型和模式是硬编码的。截至撰写本文时,没有能力更改所使用的类型或编解码器。解决这个问题的一种方法是在部署 connector(连接器)之前创建表,从而强制执行您的 schema(模式)。

  • Exporter 不是与核心 OTEL 发行版一起发布的,而是作为
    contrib
    镜像中的扩展。实际上这意味着在 Helm chart 中使用正确的 docker 镜像。

  • Exporter 处于 alpha 阶段,尽管我们在收集超过 1TB 的日志时没有遇到问题,但用户应
    遵循 Open Telemetry 提供的建议
    。OTEL 的日志用例相对较新,不如 Fluent Bit 或 Vector 成熟。

  • https://github.com/open-telemetry/opentelemetry-collector#alpha

Kubernetes 部署

image

如果仅收集日志,则
官方 Helm charts
代表最简单的部署方式。在未来的文章中,当我们对应用程序进行 instrument(仪器化)时,
operator(操作员)
会提供 auto-instrumentation(自动仪器化)功能和其他部署模式,例如作为 sidecar。但对于日志而言,基本的 chart 就足够了。有关安装和配置 chart 的完整详细信息可以在
这里
找到,包括部署网关和代理的步骤以及示例配置。

请注意,导出器还支持 ClickHouse 的原生
TTL 特性
来进行数据管理,并依赖于按日期进行分区(由 schema(模式)强制执行)。在我们的示例中,
我们将 TTL 设置为 0
,禁用了数据过期,但这代表了一个有用的功能,并且是日志中常见的需求,可以轻松地在其他代理的模式中使用。

Data & Schema

我们之前的示例已配置聚合器将数据发送到名为
otel.otel_logs
的表。我们可以通过简单的 SELECT 确认数据的成功收集。

SELECT * FROM otel.otel_logs LIMIT 1 FORMAT Vertical

Row 1:
──────
Timestamp:          2023-01-04 17:27:29.880230118
TraceId:
SpanId:
TraceFlags:         0
SeverityText:
SeverityNumber:     0
ServiceName:
Body:               {"level":"debug","ts":1672853249.8801103,"logger":"activity_tracker","caller":"logging/logger.go:161","msg":"Time tick; Starting fetch activity"}

ResourceAttributes: {'k8s.container.restart_count':'0','k8s.pod.uid':'82bc65e2-145b-4895-87fc-4a7db48e0fd9','k8s.container.name':'scraper-container','k8s.namespace.name':'ns-fuchsia-qe-86','k8s.pod.name':'c-fuchsia-qe-86-server-0'}
LogAttributes:      {'log.file.path':'/var/log/pods/ns-fuchsia-qe-86_c-fuchsia-qe-86-server-0_82bc65e2-145b-4895-87fc-4a7db48e0fd9/scraper-container/0.log','time':'2023-01-04T17:27:29.880230118Z','log.iostream':'stderr'}

1 row in set. Elapsed: 0.302 sec. Processed 16.38 thousand rows, 10.59 MB (54.18 thousand rows/s., 35.02 MB/s.)

请注意,Collector 对 schema 有特定的要求,包括强制执行特定的编解码器。虽然这些选择对于一般情况来说是合理的,但它阻止了用户根据自己的需求调整配置,例如修改表的排序键以适应特定的访问模式。

Schema 使用 PARTITION BY 来辅助 TTL。具体来说,这允许一天的数据被高效地删除。这可能会对查询产生正面和负面的影响。使用数据跳过的 Bloom 索引是一个高级主题,我们将其留待后续关于 schema 优化的文章中讨论。这里使用 Map 类型来存储 Kubernetes 和日志属性,这会影响我们的查询语法。

SHOW CREATE TABLE otel.otel_logs

CREATE TABLE otel.otel_logs
(
    `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    `TraceId` String CODEC(ZSTD(1)),
    `SpanId` String CODEC(ZSTD(1)),
    `TraceFlags` UInt32 CODEC(ZSTD(1)),
    `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
    `SeverityNumber` Int32 CODEC(ZSTD(1)),
    `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
    `Body` String CODEC(ZSTD(1)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_log_attr_value mapValues(LogAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1

Vector (beta)

Vector
是一个由 DataDog 维护的开源(采用 Mozilla 公共许可版本 2.0)可观测性数据管道工具,支持日志、指标和追踪数据的收集、转换和路由。它旨在成为供应商无关的工具,并支持多种
inputs

outputs
,包括 OTLP 协议,这使得它可以作为 Open Telemetry 代理的聚合器。使用 Rust 编写,Vector在其三阶段管道中使用
sources(源)

transforms(转换)

sinks(接收端)
这样的术语。它代表了一个功能丰富的日志收集解决方案,在 ClickHouse 社区中越来越受欢迎。

ClickHouse 支持

ClickHouse 通过一个
专用的 sink
(当前处于 beta 阶段)在 Vector 中得到支持,通信通过
HTTP 协议使用 JSON 格式
并在
插入时批量请求
。虽然不如其他协议性能高,但这将数据处理卸载给 ClickHouse,并简化了网络流量调试。虽然强制执行了一个数据模型,但用户必须创建目标表并选择它们的类型和编码。
skip_unknown_fields
选项允许用户创建只包含可用列子集的表。这会导致任何不在目标表中的列被忽略。下面我们在
vector
数据库中创建一个目标表,覆盖所有
post
列,包括从Kubernetes 丰富化添加的那些。目前,我们利用一个针对容器名称过滤进行了优化的表排序键。未来的文章将讨论优化这个 schema。

CREATE database vector

CREATE TABLE vector.vector_logs
(
   `file` String,
   `timestamp` DateTime64(3),
   `kubernetes_container_id` LowCardinality(String),
   `kubernetes_container_image` LowCardinality(String),
   `kubernetes_container_name` LowCardinality(String),
   `kubernetes_namespace_labels`  Map(LowCardinality(String), String),
   `kubernetes_pod_annotations`  Map(LowCardinality(String), String),
   `kubernetes_pod_ip` IPv4,
   `kubernetes_pod_ips` Array(IPv4),
   `kubernetes_pod_labels` Map(LowCardinality(String), String),
   `kubernetes_pod_name` LowCardinality(String),
   `kubernetes_pod_namespace` LowCardinality(String),
   `kubernetes_pod_node_name` LowCardinality(String),
   `kubernetes_pod_owner` LowCardinality(String),
   `kubernetes_pod_uid` LowCardinality(String),
   `message` String,
   `source_type` LowCardinality(String),
   `stream` Enum('stdout', 'stderr')
)
ENGINE = MergeTree
ORDER BY (`kubernetes_container_name`, timestamp)

默认情况下,Vector 的
Kubernetes 日志输入
会在列名中创建带有
.
的列,例如,
kubernetes.pod_labels
。我们
不推荐在 Map 列名中使用点号
,并可能废弃这种方法,因此建议使用下划线
_
。转换在聚合器中实现了这一点(见下文)。注意我们如何同时获取 namespace(命名空间)和节点标签。

Kubernetes 部署

image

再次,我们使用 Helm 作为首选的安装方法,利用官方 chart。聚合器和代理的完整安装细节在这里,还包括示例配置。除了将输出源更改为 ClickHouse 之外,主要的变化是需要使用一个 remap 转换,它使用 Vector Remap Language (VRL)确保列使用
_
作为分隔符而不是
.

Data

我们可以使用一个简单的查询确认日志数据正在被插入:

SELECT *
FROM vector.vector_logs
LIMIT 1
FORMAT Vertical

Row 1:
──────
file:                        /var/log/pods/argocd_argocd-application-controller-0_33574e53-966a-4d54-9229-205fc2a4ea03/application-controller/0.log
timestamp:                   2023-01-05 12:12:50.766
kubernetes_container_id:
kubernetes_container_image:  quay.io/argoproj/argocd:v2.3.3
kubernetes_namespace_labels: {'kubernetes.io/metadata.name':'argocd','name':'argocd'}
kubernetes_node_labels:      {'beta.kubernetes.io/arch':'amd64','beta.kubernetes.io/instance-type':'r5.xlarge'...}
kubernetes_container_name:   application-controller
kubernetes_pod_annotations:  {'ad.agent.com/application-controller.check_names':'["openmetrics"]...'}
kubernetes_pod_ip:           10.1.3.30
kubernetes_pod_ips:          ['10.1.3.30']
kubernetes_pod_labels:       {'app.kubernetes.io/component':'application-controller'...}
kubernetes_pod_name:         argocd-application-controller-0
kubernetes_pod_namespace:    argocd
kubernetes_pod_node_name:    ip-10-1-1-210.us-west-2.compute.internal
kubernetes_pod_owner:        StatefulSet/argocd-application-controller
kubernetes_pod_uid:          33574e53-966a-4d54-9229-205fc2a4ea03
message:                     {"level":"info","msg":"Ignore '/spec/preserveUnknownFields' for CustomResourceDefinitions","time":"2023-01-05T12:12:50Z"}
source_type:                 kubernetes_logs
stream:                      stderr

Fluent Bit

Fluent Bit
是一个日志和指标 processor(处理器)及 forwarder(转发器)。它最初专注于日志处理,并用 C 语言编写以尽量减少任何开销,FluentBit 致力于轻量级和高速度。该代码最初由 TreasureData 开发,但早已作为开源项目在 Cloud Native Computing Foundation 下发布,遵循 Apache 2.0 许可证。
多个云提供商
将其作为第一类公民采用,它提供了与上述工具相当的
输入、处理和输出
功能。

FluentBit 使用
intputs(输入)

parsers(解析器)
/
filters(过滤器)

outputs(输出)
为其 pipeline(管道)(以及
buffer(缓冲区)

router(路由器)
概念,这些超出了本文范围)。聚合实例被称为
aggregator(聚合器)

ClickHouse support

FluentBit 没有专门针对 ClickHouse 的输出插件,而是依赖通用的
HTTP 支持
。这种方法工作良好,并依赖于将数据以 JSONEachRow 格式插入。然而,用户需要注意这种方法不执行批处理。因此,需要适当配置 FluentBit 来避免大量的小批量插入和
“too many part” 问题
。用户应当知道 Fluent Bit 将所有内容存储为块。这些
chunks(块)
具有 tag 和最多 2MB 的有效负载大小的数据结构。当使用 Kubernetes 时,每个容器会输出到一个由动态 tag 标识的单独文件。Tag 也用于读取各个块。这些块按 tag 独立地由代理程序按照
刷新间隔
刷新到 aggregator(聚合器)。聚合器保留 tag 信息以满足下游路由需求。它会根据每个 tag 设置自己的刷新间隔来确定对 ClickHouse 的写入操作。因此,用户有两种选择:

https://docs.fluentbit.io/manual/pipeline/outputs/http

https://clickhouse.com/blog/common-getting-started-issues-with-clickhouse

https://docs.fluentbit.io/manual/administration/buffering-and-storage#chunks
https://docs.fluentbit.io/manual/v/1.3/configuration/file#config_section

  • 配置较大的刷新间隔,例如至少
    10
    秒,在代理和聚合器上。这可能是有效的,但也可能导致 thundering-herd effect(惊群效应),导致向 ClickHouse 插入数据时出现峰值。但是,如果间隔足够大,内部合并应该能够跟上。

  • 配置输出使用 ClickHouse 的异步插入 - 如果你不部署聚合器实例,特别推荐这种方法。这会导致 ClickHouse 缓冲插入操作,是处理这种写模式的
    推荐方法

    异步插入的行为
    可以通过影响 Fluent Bit 的交付保证进行调整。具体来说,设置
    wait_for_async_insert
    控制写入是否在写入 buffer 时得到确认(0)或在实际写入为数据部分并可用于查询时得到确认。值为 1 提供了更强的交付保证,但可能降低吞吐量。注意 Fluent Bit 的偏移管理及推进基于输出的确认。对于
    wait_for_async_insert
    设置为 0 的情况,意味着数据在完全处理之前就得到了确认,即后续可能出现失败导致数据丢失。在某些情况下,这可能是可以接受的。注意还有设置
    async_insert_max_data_size

    async_insert_busy_timeout_ms
    ,它们控制缓冲区的确切刷新行为。


没有明确理解 ClickHouse 的情况下,用户必须在部署前预创建表。与 Vector 类似,这将 schema 决策留给了用户。FluentBit 创建了一个嵌套深度大于 1 的 JSON Schema。这可能会包含数百个字段,因为每个唯一的 label 或 annotation 都会创建一个独特的列。我们之前的帖子建议使用
JSON type
为此
kubernetes
列。这将列创建推迟给 ClickHouse,并允许根据数据动态创建子列。这提供了一个很好的入门体验,但并非最优,因为用户不能使用编解码器或在表的排序键中使用特定的子列(除非使用
JSONExtract
),从而导致较差的压缩和较慢的查询。它也可能导致在没有控制 label 和 annotation 使用的环境中出现列爆炸。此外,此功能目前处于实验阶段。对此 schema 的一个更优化的方法是将 labels 和 annotations 移动到
Map type
- 这方便地将
kubernetes
列减少到一层嵌套。这需要我们在处理管道中稍微修改数据结构,并产生以下 schema。

CREATE TABLE fluent.fluent_logs
(
    `timestamp` DateTime64(9),
    `log` String,
    `kubernetes` Map(LowCardinality(String), String),
    `host` LowCardinality(String),
    `pod_name` LowCardinality(String),
    `stream` LowCardinality(String),
    `labels` Map(LowCardinality(String), String),
    `annotations` Map(LowCardinality(String), String)
)
ENGINE = MergeTree
ORDER BY (host, pod_name, timestamp)

Kubernetes 部署

image

一篇
之前的博客文章
详细讨论了将 Fluent Bit 部署到收集 Kubernetes 日志到 ClickHouse。这篇文章关注于仅部署代理架构而不部署聚合器。一般配置仍然适用,只是有一些不同之处以改进模式并引入聚合器。

聚合器和代理的完整安装细节以及示例配置可以在
这里
找到。关于配置的一些重要细节:

Data

我们可以使用简单的查询来确认日志数据正在插入:

SELECT *
FROM fluent.fluent_logs
LIMIT 1
FORMAT Vertical


Row 1:
──────
timestamp:   2023-01-05 13:11:36.452730318
log:         2023.01.05 13:11:36.452588 [ 41 ] {}  RaftInstance: Receive a append_entries_request message from 1 with LastLogIndex=298734, LastLogTerm=17, EntriesLength=0, CommitIndex=298734 and Term=17

kubernetes:  {'namespace_name':'ns-chartreuse-at-71','container_hash':'609927696493.dkr.ecr.us-west-2.amazonaws.com/clickhouse-keeper@sha256:e9efecbef9498dea6ddc029a8913dc391c49c7d0b776cb9b1c767cdb1bf15489',...}
host:        ip-10-1-3-9.us-west-2.compute.internal
pod_name:    c-chartreuse-at-71-keeper-2
stream:      stderr
labels:      {'controller-revision-hash':..}

互操作性和选择栈

我们之前的示例假设代理和聚合器都使用相同的技术。通常这并非最优选择,甚至有时不可能实现,原因可能是组织标准或代理不支持特定的数据类型。例如,如果您使用 Open Telemetry 语言代理进行追踪,则可能会部署一个 OTEL Collector 作为聚合器。在这种情况下,您可以选择 Fluent Bit 作为首选的日志收集代理(因为它在这方面更加成熟),但仍继续使用 OTEL Collector 作为聚合器以保持数据模型的一致性。

幸运的是,作为更广泛的 Open Telemetry 项目一部分推广的
OTLP 协议
以及对 forward 协议的支持(这是 Fluent Bit 偏好的通信标准)在某些情况下允许实现互操作性。

Vector 支持这些协议作为数据源,并可以作为 Fluent Bit 和 Open Telemetry Collector 的日志聚合器。但是,它不支持这些协议作为接收端,这使得在已经部署了 OTEL Collector 或 Fluent Bit 的环境中将其作为代理来部署变得具有挑战性。请注意,Vector 对于您应该用 Vector 替换的栈中的哪些组件
有着明确的观点

Fluent Bit 最近增加了 OTLP
支持作为输入

输出
,这可能允许与 OTEL Collector(也支持
forward 协议作为接收器
)实现高度的互操作性。作为日志收集代理的 Fluent Bit,通过 forward 或 OTLP 协议向 OTEL Collector 发送数据,正变得越来越流行,尤其是在 Open Telemetry 已经成为标准的环境中。

注意:截至本文撰写之时,我们在 Fluent Bit 的 OTLP 输入和输出方面遇到了
一些问题
,但我们预计这些问题很快会得到解决。

下面总结了当前日志收集的兼容状态,并链接到示例 Helm 配置,其中详细说明了已知的问题,这些可以参照上面的例子。
请注意,这里仅讨论日志收集。

image

https://github.com/ClickHouse/examples/tree/main/observability/logs/kubernetes

当代理配置为聚合器以从不同的技术接收事件时,最终的数据模式会与等效的同构架构不同。上述链接展示了结果数据模式的例子。如果需要一致的数据模式,用户可能需要利用每个代理的转换功能。

压缩

将日志数据存储在 ClickHouse 中的主要好处之一就是其出色的压缩性能:这是由于它的列式设计和可配置的编解码器。以下查询显示,在之前收集的数据上,根据不同的聚合器,我们的压缩率范围从 14 倍到 30 倍不等。这些结果代表的是未经优化的模式(尽管默认的 OTEL 模式是合理的),因此通过调整可以实现进一步的压缩。细心的读者会注意到,我们排除了 Kubernetes 的 labels 和 annotations,这些默认添加到了 Fluent Bit 和 Vector 的部署中,但在 OTEL Collector 中并未添加(虽然 OTEL Collector 支持这一点,但需要
额外的配置
)。这些数据非常稀疏,并且因为大多数 annotations 只存在于一小部分 Pod 上而被压缩得非常好。这扭曲了压缩比率(提高了它们),因为大多数值为空,所以我们选择排除它们 — 好消息是这些数据在被压缩后占用的空间很小。

SELECT
    database,
    table,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (database IN ('fluent', 'vector', 'otel')) AND (name NOT LIKE '%labels%') AND (name NOT LIKE '%annotations%')
GROUP BY
    database,
    table
ORDER BY
    database ASC,
    table ASC

┌─database─┬─table───────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ fluent   │ fluent_logs │ 2.43 GiB        │ 80.23 GiB         │ 33.04 │
│ otel     │ otel_logs   │ 5.57 GiB        │ 78.51 GiB         │  14.1 │
│ vector   │ vector_logs │ 3.69 GiB        │ 77.92 GiB         │ 21.13 │
└──────────┴─────────────┴─────────────────┴───────────────────┴───────┘

我们将在后续的文章中探讨这些变化的压缩率的原因,但即使是初次尝试,上述的压缩率也显示出相对于其他解决方案的巨大潜力。这些 schemas(模式)可以被规范化,并且可以独立于代理实现类似的压缩率,因此这些结果不应该用来比较代理。

关于注释高压缩率的一个例子:

SELECT
    name,
    table,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (database IN ('fluent', 'vector', 'otel')) AND ((name LIKE '%labels%') OR (name LIKE '%annotations%'))
GROUP BY
    database,
    table,
    name
ORDER BY
    database ASC,
    table ASC

┌─name────────────────────────┬─table───────┬─compressed_size─┬─uncompressed_size─┬──ratio─┐
│ labels                      │ fluent_logs │ 2.95 MiB        │ 581.31 MiB        │ 196.93 │
│ annotations                 │ fluent_logs │ 14.97 MiB       │ 7.17 GiB          │ 490.57 │
│ kubernetes_pod_annotations  │ vector_logs │ 36.67 MiB       │ 23.97 GiB         │ 669.29 │
│ kubernetes_node_labels      │ vector_logs │ 18.67 MiB       │ 4.18 GiB          │ 229.55 │
│ kubernetes_pod_labels       │ vector_logs │ 6.89 MiB        │ 1.88 GiB          │ 279.92 │
│ kubernetes_namespace_labels │ vector_logs │ 3.91 MiB        │ 468.14 MiB        │ 119.62 │
└─────────────────────────────┴─────────────┴─────────────────┴───────────────────┴────────┘

我们将在未来的文章中深入探讨这一点,但在此期间推荐阅读
使用 Schemas and Codecs 优化 ClickHouse

查询 & 可视化日志

常见查询

日志数据本质上是 time-series(时间序列) 数据,ClickHouse 提供了许多函数来辅助查询。我们在
最近的一篇博客文章
中对此进行了详尽的介绍,其中大多数查询概念都是相关的。大多数仪表板和调查都需要按时间进行聚合以绘制时间序列图表,随后再根据 server/pod 名称或错误代码进行过滤。下面的示例使用了 Vector 收集的日志,但这些示例可以适应其他收集类似字段的代理数据。

按 pod 名称随时间变化的日志

在这里,我们按照自定义的时间间隔进行分组,并使用填充来填充缺失的分组。根据需要进行调整。请参阅我们最近的
博客
获取更多详情。

SELECT
    toStartOfInterval(timestamp, toIntervalDay(1)) AS time,
    kubernetes_pod_name AS pod_name,
    count() AS c
FROM vector.vector_logs
GROUP BY
    time,
    pod_name
ORDER BY
    pod_name ASC,
    time ASC WITH FILL STEP toIntervalDay(1)
LIMIT 5

┌────────────────time─┬─pod_name──────────────────────────────────────────┬─────c─┐
│ 2023-01-05 00:00:00 │ argocd-application-controller-0                   │  8736 │
│ 2023-01-05 00:00:00 │ argocd-applicationset-controller-745c6c86fd-vfhzp │     9 │
│ 2023-01-05 00:00:00 │ argocd-notifications-controller-54495dd444-b824r  │ 15137 │
│ 2023-01-05 00:00:00 │ argocd-repo-server-d4787b66b-ksjps                │  2056 │
│ 2023-01-05 00:00:00 │ argocd-server-58dd79dbbf-wbthh                    │     9 │
└─────────────────────┴───────────────────────────────────────────────────┴───────┘

5 rows in set. Elapsed: 0.270 sec. Processed 15.62 million rows, 141.97 MB (57.76 million rows/s., 524.86 MB/s.)

在特定时间窗口内针对容器的日志

SELECT
    timestamp,
    kubernetes_pod_namespace AS namespace,
    kubernetes_pod_name AS pod,
    kubernetes_container_name AS container,
    message
FROM vector.vector_logs
WHERE (kubernetes_pod_name = 'argocd-application-controller-0') AND ((timestamp >= '2023-01-05 13:40:00.000') AND (timestamp <= '2023-01-05 13:45:00.000'))
ORDER BY timestamp DESC
LIMIT 2
FORMAT Vertical

Row 1:
──────
timestamp: 2023-01-05 13:44:41.516
namespace: argocd
pod:       argocd-application-controller-0
container: application-controller
message:   W0105 13:44:41.516636       1 warnings.go:70] policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+

Row 2:
──────
timestamp: 2023-01-05 13:44:09.515
namespace: argocd
pod:       argocd-application-controller-0
container: application-controller
message:   W0105 13:44:09.515884       1 warnings.go:70] policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+

2 rows in set. Elapsed: 0.219 sec. Processed 1.94 million rows, 21.59 MB (8.83 million rows/s., 98.38 MB/s.)

查询 Map 类型

上述许多代理产生相似的模式,并使用 Map 数据类型来处理 Kubernetes 的 annotations 和 labels。用户可以通过
map notation
法访问嵌套键,此外还可以使用专门的 ClickHouse
map functions
来进行过滤或选择这些列。

SELECT
    kubernetes_pod_labels['statefulset.kubernetes.io/pod-name'] AS statefulset_pod_name,
    count() AS c
FROM vector.vector_logs
WHERE statefulset_pod_name != ''
GROUP BY statefulset_pod_name
ORDER BY c DESC
LIMIT 10

┌─statefulset_pod_name────────┬──────c─┐
│ c-snow-db-40-keeper-2       │ 587961 │
│ c-coral-cy-94-keeper-0      │ 587873 │
│ c-ivory-es-35-keeper-2      │ 587331 │
│ c-feldspar-hh-33-keeper-2   │ 587169 │
│ c-steel-np-64-keeper-2      │ 586828 │
│ c-fuchsia-qe-86-keeper-2    │ 583358 │
│ c-canary-os-78-keeper-2     │ 546849 │
│ c-salmon-sq-90-keeper-1     │ 544693 │
│ c-claret-tk-79-keeper-2     │ 539923 │
│ c-chartreuse-at-71-keeper-1 │ 538370 │
└─────────────────────────────┴────────┘

10 rows in set. Elapsed: 0.343 sec. Processed 16.98 million rows, 3.15 GB (49.59 million rows/s., 9.18 GB/s.)

// use groupArrayDistinctArray to list all pod label keys
SELECT groupArrayDistinctArray(mapKeys(kubernetes_pod_annotations))
FROM vector.vector_logs
LIMIT 10

['clickhouse.com/chi','clickhouse.com/namespace','release','app.kubernetes.io/part-of','control-plane-id','controller-revision-hash','app.kubernetes.io/managed-by','clickhouse.com/replica','kind','chart','heritage','cpu-request','memory-request','app.kubernetes.io/version','app','clickhouse.com/ready','clickhouse.com/shard','clickhouse.com/settings-version','control-plane','name','app.kubernetes.io/component','updateTime','clickhouse.com/app','role','pod-template-hash','app.kubernetes.io/instance','eks.amazonaws.com/component','clickhouse.com/zookeeper-version','app.kubernetes.io/name','helm.sh/chart','k8s-app','statefulset.kubernetes.io/pod-name','clickhouse.com/cluster','component','pod-template-generation']

查找包含特定字符串的日志的容器

可以通过 ClickHouse 的
字符串和正则表达式
函数对日志行进行模式匹配,如下所示:

SELECT
    kubernetes_pod_name,
    count() AS c
FROM vector.vector_logs
WHERE message ILIKE '% error %'
GROUP BY kubernetes_pod_name
ORDER BY c DESC
LIMIT 5

┌─kubernetes_pod_name──────────────────────────────────────────┬───c─┐
│ falcosidekick-ui-redis-0                                     │ 808 │
│ clickhouse-operator-clickhouse-operator-helm-dc8f5789b-lb88m │  48 │
│ argocd-repo-server-d4787b66b-ksjps                           │  37 │
│ kube-metric-forwarder-7df6d8b686-29bd5                       │  22 │
│ c-violet-sg-87-keeper-1                                      │  22 │
└──────────────────────────────────────────────────────────────┴─────┘

5 rows in set. Elapsed: 0.578 sec. Processed 18.02 million rows, 2.79 GB (31.17 million rows/s., 4.82 GB/s.)

使用正则表达式查找有问题的容器

SELECT
    kubernetes_pod_name,
    arrayCompact(extractAll(message, 'Cannot resolve host \\((.*)\\)')) AS cannot_resolve_host
FROM vector.vector_logs
WHERE match(message, 'Cannot resolve host')
LIMIT 5
FORMAT PrettyCompactMonoBlock

┌─kubernetes_pod_name─────┬─cannot_resolve_host──────────────────────────────────────────────────────────────────────────┐
│ c-violet-sg-87-keeper-0 │ ['c-violet-sg-87-keeper-1.c-violet-sg-87-keeper-headless.ns-violet-sg-87.svc.cluster.local'] │
│ c-violet-sg-87-keeper-0 │ ['c-violet-sg-87-keeper-2.c-violet-sg-87-keeper-headless.ns-violet-sg-87.svc.cluster.local'] │
│ c-violet-sg-87-keeper-0 │ ['c-violet-sg-87-keeper-1.c-violet-sg-87-keeper-headless.ns-violet-sg-87.svc.cluster.local'] │
│ c-violet-sg-87-keeper-0 │ ['c-violet-sg-87-keeper-2.c-violet-sg-87-keeper-headless.ns-violet-sg-87.svc.cluster.local'] │
│ c-violet-sg-87-keeper-0 │ ['c-violet-sg-87-keeper-1.c-violet-sg-87-keeper-headless.ns-violet-sg-87.svc.cluster.local'] │
└─────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────┘

5 rows in set. Elapsed: 0.690 sec. Processed 18.04 million rows, 2.76 GB (26.13 million rows/s., 3.99 GB/s.)

查询性能优化

对于上述代理生成的数据的查询性能主要取决于在创建表时定义的排序键。这些键应与您的典型工作流程和访问模式相匹配。确保您在工作流程中经常用于过滤的列出现在
ORDER BY
表声明中。这些列的排序还应考虑它们各自的基数,以确保 ClickHouse 可以使用最佳的过滤算法。在大多数情况下,按照
基数递增的顺序
排列您的列。对于日志来说,这意味着通常先放置服务器或容器名称,然后是时间戳:但这取决于您计划如何进行过滤。超过 3-4 列的键内的列通常不建议使用,并且提供的价值不大。相反,可以考虑加速查询的替代方案,如在文章
超级加速您的 ClickHouse 查询

在 ClickHouse 中处理时间序列数据
中讨论的那样。

Map 类型在这篇文章中的许多模式中都很常见。这种类型要求值和键具有相同的类型——这对于 Kubernetes 标签来说是足够的。需要注意的是,在查询 Map 类型的子键时,整个父列会被加载。如果映射中有许多键,这可能会导致显著的查询开销。如果您需要频繁查询特定的键,请考虑将其移动到根级别的专用列中。

需要注意的是,我们目前发现 OTEL Collector 默认的表模式和排序键在数据集变大时会使某些查询变得昂贵,特别是如果您的访问模式与键不匹配的话。用户应根据自己的工作流程评估模式,并提前创建表以避免这种情况。

OTEL 模式为使用 TTL 来管理数据提供了灵感。这对于日志数据尤其相关,因为日志数据通常只需要保留几天就可以删除。需要注意的是,分区可能会对查询性能产生正面或负面的影响:如果大多数查询只命中单个分区,查询性能会有所提高。相反,如果查询通常会命中多个分区,则可能导致性能下降。

最后,即使您的访问模式与排序键不完全匹配,ClickHouse 中的线性扫描也非常快,使得大多数查询仍然可行。未来的一篇文章将更详细地探讨针对日志优化模式和排序键的方法。

可视化工具

我们目前推荐使用
官方 ClickHouse 插件
的 Grafana 来可视化和探索日志数据。之前的
帖子

视频
已经深入探讨了这个插件。

我们之前使用 Fluent Bit 的
博客文章
演示了如何在 Grafana 中可视化来自 Kubernetes 的日志数据。可以从
这里
下载此仪表板,并像下面所示
导入到 Grafana 中
——请注意仪表板 id 为
17284
。将此适配到特定代理的选择留给读者自行完成。

image

此仪表板的只读版本可从
这里
获得。

结论

本篇博客展示了如何利用多种代理和技术轻松地收集并存储日志数据到 ClickHouse。虽然我们使用了现代的 Kubernetes 架构来说明这一点,但这些工具同样适用于更传统的自管理服务器或容器编排系统。我们也提到了查询以及可能的互操作方法和挑战。为了进一步阅读,我们鼓励用户探索超出本文的主题,例如代理如何处理队列、背压以及它们承诺的交付保证。我们将在后续的文章中探讨这些主题,并向我们的 ClickHouse 实例中添加指标和追踪数据,然后再探讨如何优化 schemas 以及如何利用生命周期功能管理数据。

更多

详解更多边缘检测内容

已经见识到用卷积运算实现垂直边缘检测,在本博客中,将看到如何区分正边和负边,这实际就是由亮到暗与由暗到亮的区别,也就是边缘的过渡。还能了解到其他类型的边缘检测以及如何去实现这些算法,而不要总想着去自己编写一个边缘检测程序。

这张6×6的图片,左边较亮,而右边较暗,将它与垂直边缘检测滤波器进行卷积,检测结果就显示在了右边这幅图的中间部分。

现在这幅图有什么变化呢?它的颜色被翻转了,变成了左边比较暗,而右边比较亮。现在亮度为10的点跑到了右边,为0的点则跑到了左边。如果用它与相同的过滤器进行卷积,最后得到的图中间会是-30,而不是30。如果将矩阵转换为图片,就会是该矩阵下面图片的样子。现在中间的过渡部分被翻转了,之前的30翻转成了-30,表明是由暗向亮过渡,而不是由亮向暗过渡。

如果不在乎这两者的区别,可以取出矩阵的绝对值。但这个特定的过滤器确实可以为区分这两种明暗变化的区别。

再来看看更多的边缘检测的例子,已经见过这个3×3的过滤器,它可以检测出垂直的边缘。所以,看到右边这个过滤器,想应该猜出来了,它能让检测出水平的边缘。提醒一下,一个垂直边缘过滤器是一个3×3的区域,它的左边相对较亮,而右边相对较暗。相似的,右边这个水平边缘过滤器也是一个3×3的区域,它的上边相对较亮,而下方相对较暗。

这里还有个更复杂的例子,左上方和右下方都是亮度为10的点。如果将它绘成图片,右上角是比较暗的地方,这边都是亮度为0的点,把这些比较暗的区域都加上阴影。而左上方和右下方都会相对较亮。如果用这幅图与水平边缘过滤器卷积,就会得到右边这个矩阵。

再举个例子,这里的30(右边矩阵中绿色方框标记元素)代表了左边这块3×3的区域(左边矩阵绿色方框标记部分),这块区域确实是上边比较亮,而下边比较暗的,所以它在这里发现了一条正边缘。而这里的-30(右边矩阵中紫色方框标记元素)又代表了左边另一块区域(左边矩阵紫色方框标记部分),这块区域确实是底部比较亮,而上边则比较暗,所以在这里它是一条负边。

再次强调,现在所使用的都是相对很小的图片,仅有6×6。但这些中间的数值,比如说这个10(右边矩阵中黄色方框标记元素)代表的是左边这块区域(左边6×6矩阵中黄色方框标记的部分)。这块区域左边两列是正边,右边一列是负边,正边和负边的值加在一起得到了一个中间值。但假如这个一个非常大的1000×1000的类似这样棋盘风格的大图,就不会出现这些亮度为10的过渡带了,因为图片尺寸很大,这些中间值就会变得非常小。

总而言之,通过使用不同的过滤器,可以找出垂直的或是水平的边缘。但事实上,对于这个3×3的过滤器来说,使用了其中的一种数字组合。

但在历史上,在计算机视觉的文献中,曾公平地争论过怎样的数字组合才是最好的,所以还可以使用这种:
\(\begin{bmatrix}1 & 0 & - 1 \\ 2 & 0 & - 2 \\ 1 & 0 & - 1 \\\end{bmatrix}\)
,叫做
Sobel
的过滤器,它的优点在于增加了中间一行元素的权重,这使得结果的鲁棒性会更高一些。

但计算机视觉的研究者们也会经常使用其他的数字组合,比如这种:
\(\begin{bmatrix} 3& 0 & - 3 \\ 10 & 0 & - 10 \\ 3 & 0 & - 3 \\\end{bmatrix}\)
,这叫做Scharr过滤器,它有着和之前完全不同的特性,实际上也是一种垂直边缘检测,如果将其翻转90度,就能得到对应水平边缘检测。

随着深度学习的发展,学习的其中一件事就是当真正想去检测出复杂图像的边缘,不一定要去使用那些研究者们所选择的这九个数字,但可以从中获益匪浅。把这矩阵中的9个数字当成9个参数,并且在之后可以学习使用反向传播算法,其目标就是去理解这9个参数。

当得到左边这个6×6的图片,将其与这个3×3的过滤器进行卷积,将会得到一个出色的边缘检测。把这9个数字当成参数的过滤器,通过反向传播,可以学习这种
\(\begin{bmatrix}1 & 0 & - 1 \\ 1 & 0 & - 1 \\ 1 & 0 & - 1 \\\end{bmatrix}\)
的过滤器,或者
Sobel
过滤器和
Scharr
过滤器。还有另一种过滤器,这种过滤器对于数据的捕捉能力甚至可以胜过任何之前这些手写的过滤器。相比这种单纯的垂直边缘和水平边缘,它可以检测出45°或70°或73°,甚至是任何角度的边缘。所以将矩阵的所有数字都设置为参数,通过数据反馈,让神经网络自动去学习它们,会发现神经网络可以学习一些低级的特征,例如这些边缘的特征。尽管比起那些研究者们,要更费劲一些,但确实可以动手写出这些东西。不过构成这些计算的基础依然是卷积运算,使得反向传播算法能够让神经网络学习任何它所需要的3×3的过滤器,并在整幅图片上去应用它。这里,这里,还有这里(左边矩阵蓝色方框标记部分),去输出这些,任何它所检测到的特征,不管是垂直的边缘,水平的边缘,还有其他奇怪角度的边缘,甚至是其它的连名字都没有的过滤器。

所以这种将这9个数字当成参数的思想,已经成为计算机视觉中最为有效的思想之一。