服务稳定性大盘需要将服务调用关系绘制为有向图,并按 QPS 为节点和调用线着色。现有图表库未能直接满足动态拓扑图的需求,因此使用 D3.js 实现。本文说明基础概念:选择集(selection)和数据绑定(data join)。
用的是 D3 v3(页面上直接 <script src="d3.min.js"></script> 引入的那种),下面代码都是按 v3 的 API 写的,命名空间比如 d3.svg.line()、d3.scale.linear() 这种写法要认准版本,不然网上查到的例子会对不上。
D3 与 jQuery 的操作方式
D3 和 jQuery 都使用 d3.select('#chart') 这类链式调用,但两者处理数据变化的方式不同。
jQuery 的操作方式是先选中已经存在的 DOM 元素,再改它的属性。比如页面上已经有 5 个 .dot,用 $('.dot').css('fill', 'red') 把它们都改成红色。如果数据变成了 8 条,该新增几个节点、该删除几个节点,这部分逻辑得自己写判断。
D3 的思路是把一个数组和一组 DOM 节点绑定在一起,绑定之后如果数据条数和已有节点数不一致,会分成三种情况处理:
- 数据比节点多,多出来的数据没有对应节点,这部分叫 enter(进入)
- 数据和节点一一对应,这部分叫 update(更新),也就是普通的选择集
- 节点比数据多,多出来的节点没有数据对应,这部分叫 exit(退出)
D3 的名称来自 Data-Driven Documents(数据驱动文档)。数据条数变化时,enter、update 和 exit 提供了一套处理 DOM 增删和更新的固定模式。
示例:绘制一组圆点
先看最简单的场景,不考虑增删,只是把一个数组画成一组圆点,圆点半径按数值大小变化。
var dataset = [5, 10, 15, 20, 25];
var svg = d3.select("#chart")
.append("svg")
.attr("width", 300)
.attr("height", 60);
svg.selectAll("circle")
.data(dataset)
.enter()
.append("circle")
.attr("cx", function(d, i) {
return i * 60 + 30;
})
.attr("cy", 30)
.attr("r", function(d) {
return d;
})
.style("fill", "steelblue");从上到下看:d3.select("#chart") 选中 id 为 chart 的容器,.append("svg") 往里面加一个 svg 标签,返回的是这个新加元素的选择集,后面的 .attr() 都是在设置这个 svg 的宽高。
svg.selectAll("circle") 会查找 svg 中已有的 circle 元素。此时页面中没有 circle,因此得到空选择集。随后 .data() 绑定数据,D3 比较数据条数与已有节点数;没有对应节点的数据进入 enter 选择集,再由 .append() 创建元素。这个模式先选择、再绑定数据,顺序正确。
.data(dataset) 把数组 dataset 和刚才选出来的空选择集绑定。绑定之后 D3 会对比数据条数(5 条)和节点数(0 个),数据比节点多 5 个,这 5 条数据全部进入 enter 选择集。
.enter() 拿到 enter 选择集,也就是那 5 条还没有对应节点的数据。
.append("circle") 对 enter 选择集里的每一条数据新建一个 circle 元素并插入 DOM,返回的选择集是这 5 个新建的 circle。
后面 .attr("cx", ...)、.attr("r", ...)、.style("fill", ...) 都是在这 5 个新建元素上设置属性。.attr() 和 .style() 的第二个参数可以是一个函数,D3 会对选择集里的每个元素分别调用这个函数,参数 d 是绑定的那条数据,i 是这条数据在数组里的下标(从 0 开始)。cx 用下标算横坐标间距,r 直接用数值当半径。
enter、update 与 exit
上面的例子只处理了 enter,因为一开始页面是空的。实际场景里数据会变,比如大盘里的服务节点会增加或者下线,圆点个数要跟着变,这时候三种选择集都要处理。
把画圆点的逻辑拆成一个函数,调用两次,模拟数据变化:
function render(dataset) {
var circles = svg.selectAll("circle")
.data(dataset, function(d) {
return d.id;
});
// enter:新增的数据,创建新节点
circles.enter()
.append("circle")
.attr("cy", 30)
.attr("r", 0)
.style("fill", "steelblue");
// update:已有数据,更新已有节点
// enter 部分也会走一遍这里,因为 enter().append() 返回的元素会合并回原选择集
circles
.attr("cx", function(d, i) {
return i * 60 + 30;
})
.attr("r", function(d) {
return d.value;
});
// exit:多余的节点,直接移除
circles.exit().remove();
}
render([
{ id: "a", value: 10 },
{ id: "b", value: 20 },
{ id: "c", value: 15 }
]);
// 过一段时间数据变化,b 下线了,新增了 d,c 的值变了
render([
{ id: "a", value: 10 },
{ id: "c", value: 25 },
{ id: "d", value: 8 }
]);如果不传 .data() 的第二个参数,D3 默认按数组下标匹配数据和节点。服务下线后,后续服务可能复用原有节点,导致颜色和大小对应错误。两次数组中的第 0 项即使不是同一服务,仍会匹配第一个节点。
解决办法是给 .data() 传第二个参数,一个返回 key 的函数(上面代码里的 function(d) { return d.id; }),D3 按这个 key 匹配新旧数据,而不是按下标匹配。服务节点用固定的 id(服务名)做 key,不管数组顺序怎么变、中间插入删除了几个,同一个服务始终对应同一个 DOM 节点,不会串位置。做实时更新的图表时这一点是必须处理的,不然频繁的数据刷新会让节点看起来在乱跳。
三个选择集的含义:
circles.enter():.data()之后,数据里有、但还没有对应 DOM 节点的部分。第一次渲染如果页面是空的,等于全部数据;后续渲染只是新增的那部分(按 key 匹配不到已有节点的)。如果这次没有新增数据,这个选择集是空的,后面接的.append()不会有任何效果。circles(也就是.data()直接返回的选择集,不接.enter()):数据和已有节点通过 key 能对应上的部分,也就是要更新的部分。enter().append()新建出来的节点执行完 append 之后会自动合并回这个 update 选择集,所以把公共的属性设置写在circles.attr(...)而不是分别在 enter 和 update 里各写一遍,新旧节点都能设置到。circles.exit():已有节点里、这次数据按 key 对应不上的部分,说明这些节点对应的数据已经不存在了。常见处理是.remove()直接删除,稳定性大盘里服务下线大概就是这么处理,节点直接从图上消失。
链式调用
上面代码里能看到 .attr().attr().style() 一路点下去,这是因为 D3 里大多数方法(select、selectAll、data、enter、append、attr、style 等)执行完返回的都是选择集本身或者新的选择集,所以能一直链下去,不用每次存中间变量再取出来调用。要注意的是 .enter() 和 .exit() 返回的是不同的选择集(分别是待创建和待删除的部分),链式调用到这一步之后,后面操作对象已经变了,这也是前面例子里为什么要把 circles.enter()... 和 circles.exit()... 分开写,不能全部接在一条链上。
服务大盘中的数据绑定
在服务大盘 demo中,每类图形元素都通过一次 selectAll().data().enter().append() 创建:line 绑定调用关系数组,circle 绑定服务数组,服务名标签和连线上移动的白点也分别绑定各自的数据。
demo 使用静态数据,因此只触发 enter。接入监控数据后,定期获取的 QPS 可能包含新增或下线服务,应使用 .data(newData, function(d) { return d.id; }) 按服务 id 匹配:新服务进入 enter,下线服务进入 exit,QPS 变化的服务进入 update。按下标匹配会导致服务名、颜色和节点位置对应错误。
下一步需要将服务 QPS 映射为颜色和半径,将调用量映射为线条粗细。这些映射由比例尺(scale)处理,包括 d3.scale.linear() 和 d3.scale.ordinal()。
