IoT小程序在展示中央空调采集数据和实时运行状态上的应用
利用前端语言实现跨平台应用开发似乎是大势所趋,跨平台并不是一个新的概念,“一次编译、到处运行”是老牌服务端跨平台语言Java的一个基本特性。随着时代的发展,无论是后端开发语言还是前端开发语言,一切都在朝着减少工作量,降低工作成本的方向发展。 与后端开发语言不同,利用前端语言实现跨平台有先天的优势,比如后端语言Java跨平台需要将源代码编译为class字节码文件后,再放进 Java 虚拟机运行;而前端语言JavaScript是直接将源代码放进JavaScript解释器运行。这就使得以JavaScript为跨平台语言开发的应用,可移植性非常强大。 目前跨平台技术按照解决方案分类,主要分为 Web 跨平台、容器跨平台、小程序跨平台。这里,我们主要以小程序跨端为例,测试对比IoT小程序和其他小程序在开发和应用上的优缺点。说到小程序,大家肯定想到微信小程序,实际在各大互联网公司:支付宝、百度、头条等等都有自己的小程序,小程序跨平台和Web跨平台十分类似,都是基于前端语言实现,小程序跨平台的优势在于可以调用系统底层能力,例如:蓝牙、相机等,性能方面也优于Web跨平台。 IoT小程序和大多数小程序一样,它是一套跨平台应用显示框架,它利用JS语言低门槛和API标准化大幅度降低了IoT应用的研发难度,其官方框架介绍如下: IoT小程序在前端框架能力、应用框架能力、图形框架能力都进行了适配和优化。那么接下来,我们按照其官方步骤搭建开发环境,然后结合中央空调数据采集和状态显示的实际应用场景开发物联网小程序应用。一、IoT小程序开发环境搭建 IoT小程序开发环境搭建一共分为四步,对于前端开发来说,安装NodeJS、配置cnpm、安装VSCode都是轻车熟路,不需要细讲,唯一不同的是按照官方说明安装IoT小程序的模拟器和VSCode开发插件HaaS UI,前期开发环境准备完毕,运行Demo查看一下效果,然后就可以进行IoT小程序应用开发了。搭建开发环境,安装HaaS UI插件和运行新建项目,出现一下界面说明开发环境搭建成功,就可以进行IoT小程序开发了:二、开发展示中央空调采集数据和运行状态的IoT小程序应用应用场景 中央空调的维保单位会对中央空调进行定期维护保养,定期的维护保养可排出故障隐患,减少事故发生,降低运行费用,延长设备的使用寿命,同时保障正常的工作时序。除了定期的维护保养外,还需要实时监测中央空调的运行参数(温度、累计排污量、不锈钢_腐蚀率等)和运行状态,及时发现中央空调运行过程中某些参数低于或高于报警值的问题,以便及时定位诊断中央空调存在的问题,然后进行相应的维护保养操作。架构实现 中央空调的数据采集和展示是典型的物联网应用架构,在中央空调端部署采集终端,通过Modbus通信协议采集中央空调设备参数,然后再由采集终端通过MQTT消息发送的我们的云端服务器,云端服务器接收到MQTT消息后转发到消息队列Kafka中,由云服务器上的自定义服务应用订阅Kafka主题,再存储到我们时序数据库中。下图展示了物联网应用的整体架构和IoT小程序在物联网架构中的位置: IoT小程序框架作为跨平台应用显示框架,顾名思义,其在物联网应用中主要作为显示框架开发。在传统应用中,我们使用微信小程序实现采集数据和运行状态的展示。而IoT小程序支持部署在AliOS Things、Ubuntu、Linux、MacOS、Window等系统中,这就使得我们可以灵活的将IoT小程序部署到多种设备终端中运行。 下面将以阿里云ASP-80智显面板为例,把展示中央空调采集数据和运行状态的IoT小程序部署在阿里云ASP-80智显面板中。IoT小程序开发 我们将从IoT小程序提供的前端框架能力、应用框架能力、图形框架能力来规划相应的功能开发。 IoT小程序采用Vue.js(v2.6.12)开源框架,实现了W3C标准的标签和样式子集;定义了四个应用生命周期,分别是:onLaunch,onShow,onHide,onDestroy;定义了十四个前端基础组件,除了基础的CSS样式支持外,还提供了对Less的支持;Net网络请求通过框架内置的JSAPI实现。 为了快速熟悉IoT小程序框架的开发方式,我们将在VSCode中导入官方公版案例,并以公版案例为基础框架开发我们想要的功能。简单实现通过网络请求获取中央空调采集数据并展示:1、在VSCode编辑器中导入从IoT小程序官网下载的公版案例,下载地址。2、因为IoT小程序前端框架使用的是Vue.js框架,所以在新增页面时也是按照Vue.js框架的模式,将页面添加到pages目录。我们是空调项目的IoT小程序,所以这里在pages目录下新增air-conditioning目录用于存放空调IoT小程序相关前端代码。3、在app.json中配置新增的页面,修改pages项,增加"air-conditioning": "pages/air-conditioning/index.vue"。{
"pages": {
......
"air-conditioning": "pages/air-conditioning/index.vue",
......
},
"options": {
"style": {
"theme": "theme-dark"
}
}
}4、在air-conditioning目录下新增index.vue前端页面代码,用于展示空调的采集数据是否正常及历史曲线图。设计需要开发的界面如下,页面的元素有栅格布局、Tabs 标签页、Radio单选框、日期选择框、曲线图表等元素。5、首先是实现Tabs标签页,IoT小程序没有Tabs组件,只能自己设置多个Text组件自定义样式并添加click事件来实现。 <div class="tab-list">
<fl-icon name="back" class="nav-back" @click="onBack" />
<text
v-for="(item, index) in scenes"
:key="index"
:class="'tab-item' + (index === selectedIndex ? ' tab-item-selected' : '')"
@click="tabSelected(index)"
>{{ item }}</text
>
</div>
......
data() {
return {
scenes: ["设备概览", "实时数据", "数据统计", "状态统计"],
selectedIndex: 0
};
},
......6、添加采集数据显示列表,在其他小程序框架中,尤其是以Vue.js为基础框架的小程序框架,这里有成熟的组件,而IoT小程序也是需要自己来实现。<template>
<div class="scene-wrapper" v-if="current">
<div class="label-temperature-wrapper top-title">
<div class="label-temperature-wrapper left-text">
<text class="label-temperature">设备编码:</text>
<text class="label-temperature-unit">{{deviceNo}}</text>
</div>
<div class="label-temperature-wrapper right-text">
<text class="label-temperature">数据日期:</text>
<text class="label-temperature-unit">{{collectTime}}</text>
</div>
</div>
<div class="main-wrapper">
<div class="section">
<div class="demo-block icon-block">
<div class="icons-item" v-for="(value, key, index) in IconTypes" :key="index">
<div class="label-title-wrapper">
<text class="label-title left-text">{{paramName}}</text>
<text class="label-title-unit right-text" style="padding-right: 5px;">{{paramWarn}}</text>
</div>
<div class="label-zhibiao-wrapper">
<text class="label-zhibiao">当前值:</text>
<text class="label-zhibiao-unit">{{value}}</text>
</div>
<div class="label-zhibiao-wrapper" style="margin-bottom: 10px;">
<text class="label-zhibiao">目标值:</text>
<text class="label-zhibiao-unit">{{targetValue}}</text>
</div>
</div>
</div>
</div>
</div>
</div>
</template> 在开发过程中发现,IoT小程序对样式的支持不是很全面,本来想将组件放置在同一行,一般情况下,只需要使用标准CSS样式display: inline就可以实现,但这里没有效果只能通过Flexbox进行布局在同一行。在设置字体方面,本来想把采集数据显示的描述字段加粗,用于突出显示,但是使用CSS样式font-weight无效,无论是设置数值还是blod,没有一点加粗效果。7、界面实现之后,需要发送数据请求,来查询采集数据并显示在界面上。IoT小程序通过框架内置JSAPI的Net网络提供网络请求工具。目前从官方文档和代码中来看,官方框架只提供了http请求,没有提供物联网中常用的WebSocket和MQTT工具,估计需要自定义扩展系统JSAPI实现其他网络请求。 created() {
const http = $falcon.jsapi.http
http.request({
url: 'http://服务域名/device/iot/query/data/point',
data: {
'deviceNo': '97306000000000005',
'rangeType': 'mo',
'lastPoint': '1',
'beginDateTime': '2023-02-10+16:09:42',
'endDateTime': '2023-03-12+16:09:42'
},
header: {
'Accept': 'application/json;charset=UTF-8',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/json;charset=UTF-8',
'Authorization': '有效token'
}
}, (response) => {
console.log(response)
var obj = JSON.parse(response.result)
console.log(obj.success)
console.log(JSON.parse(obj.data))
});
}, 按照官方要求编写http请求,发现默认未开启https请求:Protocol "https" not supported or disabled in libcurl。切换为http请求,返回数据为乱码,设置Accept-Encoding和Accept为application/json;charset=UTF-8仍然无效,且返回数据为JSON字符串,需要自己手动使用JSON.parse()进行转换,对于习惯于应用成熟框架的人来说,十分不友好。想了解更多关于 $falcon.jsapi.http的相关配置和实现,但是官方文档只有寥寥几句,没有详细的说明如何使用和配置,以及http请求中遇到一些常见问题的解决方式。8、IoT小程序框架提供画布组件,原则上来讲可以实现常用的曲线图表功能,但是如果使用其基础能力从零开始开发一套图表系统,耗时又耗力,所以这里尝试引入常用的图表组件库ECharts,使用ECharts在IoT小程序上显示曲线图表。执行cnpm install echarts --save安装echarts组件cnpm install echarts --save新建echarts配置文件,按需引入// 加载echarts,注意引入文件的路径
import echarts from 'echarts/lib/echarts'
// 再引入你需要使用的图表类型,标题,提示信息等
import 'echarts/lib/chart/bar'
import 'echarts/lib/chart/pie'
import 'echarts/lib/component/legend'
import 'echarts/lib/component/title'
import 'echarts/lib/component/tooltip'
export default echarts新增echarts组件ChartDemo.vue<template>
<div ref="chartDemo" style="height:200px;" ></div>
</template>
<script>
import echarts from '@/utils/echarts-config.js'
const ChartDemo = {
name: 'ChartDemo',
data() {
return {
chart: null
}
},
watch: {
option: {
handler(newValue, oldValue) {
this.chart.setOption(newValue)
},
deep: true
}
},
mounted() {
this.chart = echarts.init(this.$refs.chartDemo)
},
methods: {
setOption(option) {
this.chart && this.chart.setOption(option)
},
throttle(func, wait, options) {
let time, context, args
let previous = 0
if (!options) options = {}
const later = function() {
previous = options.leading === false ? 0 : new Date().getTime()
time = null
func.apply(context, args)
if (!time) context = args = null
}
const throttled = function() {
const now = new Date().getTime()
if (!previous && options.leading === false) previous = now
const remaining = wait - (now - previous)
context = this
args = arguments
if (remaining <= 0 || remaining > wait) {
if (time) {
clearTimeout(time)
time = null
}
previous = now
func.apply(context, args)
if (!time) context = args = null
} else if (!time && options.trailing !== false) {
time = setTimeout(later, remaining)
}
}
return throttled
}
}
}
export default ChartDemo
</script>
在base-page.js中注册全局组件......
import ChartDemo from './components/ChartDemo.vue';
export class BasePage extends $falcon.Page {
constructor() {
super()
}
beforeVueInstantiate(Vue) {
......
Vue.component('ChartDemo', ChartDemo);
}
}新建空调采集数据展示页history-charts.vue,用于展示Echarts图表<template>
<div class="scene-wrapper" v-if="current">
<div class="brightness-wrap">
<ChartBlock ref="chart2"></ChartBlock>
</div>
</div>
</template>
<script>
let option2 = {
title: {
text: '中央空调状态图',
subtext: '运行状态占比',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
data: ['开机', '关机', '报警', '故障', '空闲']
},
series: [
{
name: '运行状态',
type: 'pie',
radius: '55%',
center: ['50%', '60%'],
data: [
{ value: 335, name: '开机' },
{ value: 310, name: '关机' },
{ value: 234, name: '报警' },
{ value: 135, name: '故障' },
{ value: 1548, name: '空闲' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
export default {
props:{
current:{
type:Boolean,
default:false
}
},
data() {
return {
};
},
methods: {
},
mounted: function() {
this.$refs.chart2.setOption(option2)
}
};
</script>执行HaaS UI: Build-Debug ,显示打包成功执行HaaS UI: Simulator ,显示“当前HaaS UI: Simulator任务正在执行,请稍后再试” 本来想在模拟器上看一下Echarts显示效果,但是执行HaaS UI: Simulator时一直显示任务正在执行。然后以为是系统进程占用,但是重启、关闭进程等操作一系列操作下来,仍然显示此提示,最后将Echarts代码删除,恢复到没有Echarts的状态,又可以执行了。这里不清楚是否是IoT小程序不支持引入第三方图表组件,从官方文档中没有找到答案。后来又使用echarts的封装组件v-charts进行了尝试,结果依然不能展示。 如果不能使用第三方组件,那么只能使用IoT官方小程序提供的画布组件来自己实现图表功能,官方提供的画布曲线图示例。9、通过IoT小程序提供的组件分别实现显示中央空调采集数据的实时数据、数据统计、状态统计图表。-实现实时数据折线图<template>
<div class="scene-wrapper" v-show="current">
<div class="main-wrapper">
<div class="label-temperature-wrapper top-title">
<div class="label-temperature-wrapper left-text">
<text class="label-temperature">设备编码:</text>
<text class="label-temperature-unit">{{deviceNo}}</text>
</div>
<div class="label-temperature-wrapper right-text">
<text class="label-temperature">数据日期:</text>
<text class="label-temperature-unit">{{collectTime}}</text>
</div>
</div>
<canvas ref="c2" class="canvas" width="650" height="300"></canvas>
</div>
</div>
</template>
<script>
export default {
name: "canvas",
props: {},
data() {
return {
deviceNo: '97306000000000005',
collectTime: '2023-03-11 23:59:59'
};
},
mounted() {
this.c2();
},
methods: {
c2() {
let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c2) : this.$refs.c1.getContext("2d");
// Demo测试数据
let arr = [{key:'01:00',value:61.68},{key:'02:00',value:83.68},{key:'03:00',value:56.68},{key:'04:00',value:86.68},{key:'05:00',value:53.68},
{key:'06:00',value:41.68},{key:'07:00',value:33.68}];
this.drawStat(ctx, arr);
},
//该函数用来绘制折线图
drawStat(ctx, arr) {
//画布的款高
var cw = 700;
var ch = 300;
//内间距padding
var padding = 35;
//原点,bottomRight:X轴终点,topLeft:Y轴终点
var origin = {x:padding,y:ch-padding};
var bottomRight = {x:cw-padding,y:ch-padding};
var topLeft = {x:padding,y:padding};
ctx.strokeStyle='#FF9500';
ctx.fillStyle='#FF9500';
//绘制X轴
ctx.beginPath();
ctx.moveTo(origin.x,origin.y);
ctx.lineTo(bottomRight.x,bottomRight.y);
//绘制X轴箭头
ctx.lineTo(bottomRight.x-10,bottomRight.y-5);
ctx.moveTo(bottomRight.x,bottomRight.y);
ctx.lineTo(bottomRight.x-10,bottomRight.y+5);
//绘制Y轴
ctx.moveTo(origin.x,origin.y);
ctx.lineTo(topLeft.x,topLeft.y);
//绘制Y轴箭头
ctx.lineTo(topLeft.x-5,topLeft.y+10);
ctx.moveTo(topLeft.x,topLeft.y);
ctx.lineTo(topLeft.x+5,topLeft.y+10);
//设置字号
var color = '#FF9500';
ctx.fillStyle=color;
ctx.font = "13px scans-serif";//设置字体
//绘制X方向刻度
//计算刻度可使用的总宽度
var avgWidth = (cw - 2*padding - 50)/(arr.length-1);
for(var i=0;i<arr.length;i++){
//循环绘制所有刻度线
if(i > 0){
//移动刻度起点
ctx.moveTo(origin.x+i*avgWidth,origin.y);
//绘制到刻度终点
ctx.lineTo(origin.x+i*avgWidth,origin.y-10);
}
//X轴说明文字:1月,2月...
var txtWidth = 35;
ctx.fillText(
arr[i].key,
origin.x+i*avgWidth-txtWidth/2 + 10,
origin.y+20);
}
//绘制Y方向刻度
//最大刻度max
var max = 0;
for(var i=0;i<arr.length;i++){
if(arr[i].value>max){
max=arr[i].value;
}
}
console.log(max);
/*var max = Math.max.apply(this,arr);
console.log(max);*/
var avgValue=Math.floor(max/5);
var avgHeight = (ch-padding*2-50)/5;
for(var i=1;i<arr.length;i++){
//绘制Y轴刻度
ctx.moveTo(origin.x,origin.y-i*avgHeight);
ctx.lineTo(origin.x+10,origin.y-i*avgHeight);
//绘制Y轴文字
var txtWidth = 40;
ctx.fillText(avgValue*i,
origin.x-txtWidth-5,
origin.y-i*avgHeight+6);
}
//绘制折线
for(var i=0;i<arr.length;i++){
var posY = origin.y - Math.floor(arr[i].value/max*(ch-2*padding-50));
if(i==0){
ctx.moveTo(origin.x+i*avgWidth,posY);
}else{
ctx.lineTo(origin.x+i*avgWidth,posY);
}
//具体金额文字
ctx.fillText(arr[i].value,
origin.x+i*avgWidth,
posY
)
}
ctx.stroke();
//绘制折线上的小圆点
ctx.beginPath();
for(var i=0;i<arr.length;i++){
var posY = origin.y - Math.floor(arr[i].value/max*(ch-2*padding-50));
ctx.arc(origin.x+i*avgWidth,posY,4,0,Math.PI*2);//圆心,半径,画圆
ctx.closePath();
}
ctx.fill();
}
}
};
</script>-数据统计图表<template>
<div class="scene-wrapper" v-show="current">
<div class="main-wrapper">
<div class="label-temperature-wrapper top-title">
<div class="label-temperature-wrapper left-text">
<text class="label-temperature">设备编码:</text>
<text class="label-temperature-unit">{{deviceNo}}</text>
</div>
<div class="label-temperature-wrapper right-text">
<text class="label-temperature">数据日期:</text>
<text class="label-temperature-unit">{{collectTime}}</text>
</div>
</div>
<canvas ref="c1" class="canvas" width="650" height="300"></canvas>
</div>
</div>
</template>
<script>
export default {
name: "canvas",
props: {},
data() {
return {
deviceNo: '97306000000000005',
collectTime: '2023-03-13 20:23:36'
};
},
mounted() {
this.c1();
},
methods: {
c1() {
let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c1) : this.$refs.c1.getContext("2d");
this.draw(ctx);
},
draw(ctx){
var x0=30,//x轴0处坐标
y0=280,//y轴0处坐标
x1=700,//x轴顶处坐标
y1=30,//y轴顶处坐标
dis=30;
//先绘制X和Y轴
ctx.beginPath();
ctx.lineWidth=1;
ctx.strokeStyle='#FF9500';
ctx.fillStyle='#FF9500';
ctx.moveTo(x0,y1);//笔移动到Y轴的顶部
ctx.lineTo(x0,y0);//绘制Y轴
ctx.lineTo(x1,y0);//绘制X轴
ctx.stroke();
//绘制虚线和Y轴值
var yDis = y0-y1;
var n=1;
ctx.fillText(0,x0-20,y0);//x,y轴原点显示0
while(yDis>dis){
ctx.beginPath();
//每隔30划一个虚线
ctx.setLineDash([2,2]);//实线和空白的比例
ctx.moveTo(x1,y0-dis);
ctx.lineTo(x0,y0-dis);
ctx.fillText(dis,x0-20,y0-dis);
//每隔30划一个虚线
dis+=30;
ctx.stroke();
}
var xDis=30,//设定柱子之前的间距
width=40;//设定每个柱子的宽度
//绘制柱状和在顶部显示值
for(var i=0;i<12;i++){//假设有8个月
ctx.beginPath();
var color = '#' + Math.random().toString(16).substr(2, 6).toUpperCase();//随机颜色
ctx.fillStyle=color;
ctx.font = "13px scans-serif";//设置字体
var height = Math.round(Math.random()*220+20);//在一定范围内随机高度
var rectX=x0+(width+xDis)*i,//柱子的x位置
rectY=height;//柱子的y位置
ctx.color='#FF9500';
ctx.fillText((i+1)+'月份',rectX,y0+15);//绘制最下面的月份稳住
ctx.fillRect(rectX,y0, width, -height);//绘制一个柱状
ctx.fillText(rectY,rectX+10,280-rectY-5);//显示柱子的值
}
},
}
};
</script>-状态统计图表<template>
<div class="scene-wrapper" v-show="current">
<div class="main-wrapper">
<div class="label-temperature-wrapper top-title">
<div class="label-temperature-wrapper left-text">
<text class="label-temperature">设备编码:</text>
<text class="label-temperature-unit">{{deviceNo}}</text>
</div>
<div class="label-temperature-wrapper right-text">
<text class="label-temperature">数据日期:</text>
<text class="label-temperature-unit">{{collectTime}}</text>
</div>
</div>
<canvas ref="c3" class="canvas" width="600" height="300"></canvas>
</div>
</div>
</template>
<script>
export default {
name: "canvas",
props: {},
data() {
return {
deviceNo: '97306000000000005',
collectTime: '2023-03-13 20:29:36'
};
},
mounted() {
this.c3();
},
methods: {
c3() {
let ctx = typeof createCanvasContext === "function" ? createCanvasContext(this.$refs.c3) : this.$refs.c3.getContext("2d");
this.drawPie(ctx);
},
drawPie(pen){
// Demo测试数据
var deg = Math.PI / 180
var arr = [
{
name: "开机",
time: 8000,
color: '#7CFF00'
},
{
name: "关机",
time: 1580,
color: '#737F9C'
},
{
name: "空闲",
time: 5790,
color: '#0ECC9B'
},
{
name: "故障",
time: 4090,
color: '#893FCD'
},
{
name: "报警",
time: 2439,
color: '#EF4141'
},
];
//总价
pen.translate(30,-120);
arr.tatol = 0;
for (let i = 0; i < arr.length; i++) {
arr.tatol = arr.tatol + arr[i].time
}
var stardeg = 0
arr.forEach(el => {
pen.beginPath()
var r1 = 115
pen.fillStyle = el.color
pen.strokeStyle='#209AAD';
pen.font = "15px scans-serif";
//求出每个time的占比
var angle = (el.time / arr.tatol) * 360
//利用占比来画圆弧
pen.arc(300, 300, r1, stardeg * deg, (stardeg + angle) * deg)
//将圆弧与圆心相连接,形成扇形
pen.lineTo(300, 300)
var r2 = r1+10;
if(el.name === '关机' || el.name === '空闲')
{
r2 = r1+30
}
//给每个扇形添加数组的name
var y1 = 300 + Math.sin((stardeg + angle) * deg-angle*deg/2 ) *( r2)
var x1 = 300 + Math.cos((stardeg + angle) * deg-angle*deg/2 ) * (r2)
pen.fillText(`${el.name}`, x1, y1)
stardeg = stardeg + angle
pen.fill()
pen.stroke()
});
},
}
};
</script>三、将IoT小程序更新到ASP-80智显面板查看运行效果 将IoT小程序更新到ASP-80智显面板,在硬件设备上查看IoT应用运行效果。如果是使用PC端初次连接,那么需要安装相关驱动和配置,否则无法使用VSCode直接更新IoT小程序到ASP-80智显面板。1、如果使用Win10将IoT小程序包更新到ASP-80智显面板上,必须用到CH340串口驱动,第一次通过TypeC数据线连接设备,PC端设备管理器的端口处不显示端口,这时需要下载Windows版本的CH340串口驱动下载链接 。2、将下载的驱动文件CH341SER.ZIP解压并安装之后,再次查看PC端设备管理器端口就有了USB Serial CH340端口。3、使用SourceCRT连接ASP-80智显面板,按照官方文档说明,修改配置文件,连接好WiFi无线网,下一步通过VSCode直接更新IoT小程序到ASP-80智显面板上查看测试。4、所有准备工作就绪后,点击VSCode的上传按钮HaaS UI: Device,将应用打包并上传至ASP-80智显面板。在选择ip地址框的时候,输入我们上一步获取到的ip地址192.168.1.112,其他参数保持默认即可,上传成功后,VSCode控制台提示安装app成功。5、IoT小程序安装成功之后就可以在ASP-80智显面板上查看运行效果了。 综上所述,IoT小程序框架在跨系统平台(AliOS Things、Ubuntu、Linux、MacOS、Window等)方面提供了非常优秀的基础能力,应用的更新升级提供了多种方式,在实际业务开发过程中可以灵活选择。IoT小程序框架通过JSAPI提供了调用系统底层应用的能力,同时提供了自定义JSAPI扩展封装的方法,这样就足够业务开发通过自定义的方式满足特殊的业务需求。 虽然多家互联网公司都提供了小程序框架,但在128M 128M这样的低资源设备里输出,IoT小程序是比较领先的,它不需要另外下载APP作为小程序的容器,降低了资源的消耗,这一点是其他小程序框架所不能比拟的。 但是在前端框架方面,实用组件太少。其他小程序已发展多年,基于基础组件封装并开源的前端组件应用场景非常丰富,对于中小企业来说,习惯于使用成熟的开源组件,如果使用IoT小程序开发物联网应用可能需要耗费一定的人力物力。既然是基于Vue.js的框架,却没有提供引入其他优秀组件的文档说明和示例,不利于物联网应用的快速开发,希望官方能够完善文档,详细说明IoT小程序开发框架配置项,将来能够提供更多的实用组件。
ECMAScript Async Context 提案介绍
背景由阿里巴巴 TC39 代表主导的Async Context 提案[1] 刚在 2023年 2 月初的 TC39 会议中成为了 TC39 Stage 1 提案。提案的目标是定义在 JavaScript 的异步任务中传递数据的方案。我们先以一个同步调用中访问全局变量为例,来讲讲什么我们为什么需要定义异步上下文。设想一下,我们是一个 npm 库作者。在这个库中,我们提供了一个简单的 log 函数和 run 函数。开发者可以将他们的回调函数和一个 id 传给我们的 run 函数。run 会调用用户的回调函数,并且,开发者可以在这个回调函数中调用我们的 log 函数来生成自动被调用 run 函数时传入的 id 标注了的日志。如我们的库实现如下:// my-awesome-library
let currentId = undefined;
export function log() {
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
let prevId = currentId;
try {
currentId = id;
return cb();
} finally {
currentId = prevId;
}
}开发者可以这样调用我们的库:import { run, log } from 'my-awesome-library';
import { helper } from 'some-random-npm-library';
document.body.addEventListener('click', () => {
const id = nextId();
run(id, () => {
log('starting');
// 假设这个 helper 会调用 doSomething.
helper(doSomething);
log('done');
});
});
function doSomething() {
log("did something");
}在这个例子中,无论用户点击多少次,对于每一个 id,我们都可以看到如下的完整日志序列:[id1] starting[id1] did something[id1] done由此,我们实现了一个基于同步调用栈传递的 id 的机制,开发者不需要手动在他们的代码中传递、保存 id。这个模式非常实用,因为不是每一个函数我们都能增加调用参数用来传递额外的信息,比如我们通过 React Context[2] 在 React 中将参数透过数个中间组件传递给内嵌的目标组件中。但是,一旦我们开始引入异步操作,这个模式就开始出现问题了:document.body.addEventListener('click', () => {
const id = new Uuid();
run(id, async () => {
log('starting');
await helper(doSomething);
// 这条日志已经无法打印期望的 id 了
log('done');
});
});
function doSomething() {
// 这条日志能够打印期望的 id 取决于 helper 是否在调用 doSomething 之前 await 过
log("did something");
}而我们提案的 AsyncContext 就是为了解决这里的问题。它允许我们将 id 即通过同步调用栈传递,也可以通过异步任务链传递。// my-awesome-library
const context = new AsyncContext();
export function log() {
const currentId = context.get();
if (currentId === undefined) throw new Error('must be inside a run call stack');
console.log(`[${currentId}]`, ...arguments);
}
export function run<T>(id: string, cb: () => T) {
context.run(id, cb);
}AsyncContextAsyncContext 是一个能够将任意 JavaScript 值通过逻辑连接的同步、异步操作,传播到逻辑连接的异步操作的执行上下文的存储。它提供如下操作:class AsyncContext<T> {
// 快照当前执行上下文中所有 AsyncContext 实例的值,并返回一个函数。
// 当这个函数执行时,会将 AsyncContext 状态快照恢复为执行上下文的全局状态。
static wrap<R>(fn: (...args: any[]) => R): (...args: any[]) => R;
// 立刻执行 fn,并在 fn 执行期间将 value 设置为当前
// AsyncContext 实例的值。这个值会在 fn 过程中发起的异步操作中被
// 快照(相当于 wrap)。
run<R>(value: T, fn: () => R): R;
// 获取当前 AsyncContext 实例的值。
get(): T;
}AsyncContext.prototype.run() 与 AsyncContext.prototype.get() 分别向当前执行上下文中写入、读取 AsyncContext 实例值。而 AsyncContext.wrap() 允许我们对所有的 AsyncContext 实例在当前执行上下文保存的值进行快照,并通过返回的函数来将状态快照在后续任意时间恢复为执行上下文的全局 AsyncContext 状态。这三个操作定义了在异步任务间传播任意 JavaScript 值的最小操作接口。开发者可以通过 AsyncContext.prototype.run() 与 AsyncContext.prototype.get() 来写入、读取保存在异步上下文中的变量,而 JavaScript 运行时、任务队列实现者、框架作者可以通过 AsyncContext.wrap 来传播异步上下文变量。// 简单实现一个任务队列
const loop = {
queue: [],
addTask: (fn) => {
queue.push(AsyncContext.wrap(fn));
},
run: () => {
while (queue.length > 0) {
const fn = queue.shift();
fn();
}
},
};
const ctx = new AsyncContext();
ctx.run('1', () => {
// loop 通过 AsyncContext.wrap 对当前上下文状态进行了快照。
loop.addTask(() => {
console.log('task:', ctx.get());
});
// AsyncContext 值会通过异步任务自动传播。即使这个 timeout callback
// 在 `ctx.run` 的同步调用栈之外执行,也能获取到传播的值。
// 而且这个 timeout 比下面第二次更迟执行,ctx 的值任然是 1。
setTimeout(() => {
console.log(ctx.get()); // => 1
}, 1000);
});
ctx.run('2', () => {
// 设置一个更快执行的 timeout。
setTimeout(() => {
console.log(ctx.get()); // => 2
}, 500);
});
console.log(ctx.get()); // => undefined
// 清空任务队列。
// AsyncContext.wrap 返回的函数在执行期间恢复了快照的状态。
loop.run(); // => task: 1使用场景异步链路追踪OpenTelemetry[3] 这种应用性能监测工具(APM 工具)为了实现无感知监测(即不需要开发者修改任何业务代码),通常不能修改用户、第三方库、运行时 API。所以对于 APM 工具来说,他们不能让开发者来手动传播链路追踪数据。而现在,他们可以将链路追踪数据保存在 AsyncContext 中,并在需要判断当前异步调用链路时,从 AsyncContext 中获取当前的链路数据。这就不再需要开发者修改业务代码。如下我们看一个简单的链路追踪例子:// tracer.js
const context = new AsyncContext();
export function run(cb) {
// (a)
const span = {
// 建立异步调用链路
parent: context.get(),
// 设置当前异步调用属性
startTime: Date.now(),
traceId: randomUUID(),
spanId: randomUUID(),
};
context.run(span, cb);
}
export function end() {
// (b) 标记当前异步调用结束
const span = context.get();
span?.endTime = Date.now();
}
// 自动插桩 fetch API,注入链路追踪代码
const originalFetch = globalThis.fetch;
globalThis.fetch = (...args) => {
return run(() => {
return originalFetch(...args)
.finally(() => end());
});
};对于用户代码来说,不管需不需要支持链路追踪,clickHandler 即其内部调用的依赖函数都不需要修改:// my-app.js
import * as tracer from './tracer.js'
// 通过框架或者自动插桩,包装用户的 clickHandler
button.onclick = e => {
// (1)
tracer.run(async () => {
await clickHandler();
tracer.end();
});
};
// 用户代码
const clickHandler = () => {
return fetch("https://example.com").then(res => {
// (2)
return processBody(res.body).then(data => {
// (3)
const dialog = html`<dialog>Here's some cool data: ${data}
<button>OK, cool</button></dialog>`;
dialog.show();
});
});
}我们作为 OpenTelemetry 的维护者,这个提案是我们将 OpenTelemetry 的开发者无感知的链路追踪能力带给 Web 应用的必要特性之一。异步任务属性传递许多 JavaScript 运行时 API 如 Web API 都会提供任务调度相关的特性。这些任务通常可以设置如优先级等属性,让运行时发挥其启发式的调度逻辑,提供更好用户体验、低延迟的用户交互。通过 AsyncContext,开发者就不需要再手动传递如任务优先级这些任务属性。JavaScript 运行时可以通过 AsyncContext 获取通过异步调用传播的任务属性,默认配置子任务优先级:// 假设我们有一个简单的任务调度器
const scheduler = {
context: new AsyncContext(),
postTask(task, options) {
// 实际上,这个 task 需要被延迟到更空闲的时间执行。
// 但是我们这里管不了这么多了,立刻通过 AsyncContext.run 执行。
this.context.run({ priority: options.priority }, task);
},
currentTask() {
return this.context.get() ?? { priority: 'default' };
},
};
// 用户通过调度器 API 设置一个低优先级任务,可以在渲染空闲期执行。
const res = await scheduler.postTask(task, { priority: 'background' });
console.log(res);
async function task() {
// 通过 scheduler.currentPriority(),这个 fetch 任务和回复内容解析都可以被
// 自动设置为 'background' 优先级。
const resp = await fetch('/hello');
const text = await resp.text();
// 即使我们上面已经 await 了多个 Promise,当前任务还是我们期望的 'background' 优先级。
scheduler.currentTask(); // => { priority: 'background' }
// doStuffs 运行在 'background' 优先级,不需要我们重新通过调度器
// scheduler.postTask(doStuffs, { priority: 'background' });
// 来设置期望的优先级。
return doStuffs(text);
}
async function doStuffs(text) {
// 一些异步操作...
return text;
}以上例子是当前 WICG Scheduling APIs[4] 待解决的一个难点[5]。我们目前也正在与 Chrome Web Performance 团队讨论基于 AsyncContext 的 Web API 拓展的设计。Prior Arts线程局部变量线程作为一个程序执行单元,它们有自己的 Program Counter 等等,但是线程之间可以共享整个进程的内存空间访问。但正是因为内存的共享,内存安全是每一个使用线程的程序都需要考虑的问题。而采用线程局部变量 (thread_local)[6]可以以更低的兼容成本来为已有的函数提供可重入的能力。可见线程局部变量设计初衷是为了解决传统 API 的可重入问题的,如 glibc 中许多函数都需要使用到一个变量 errno[7] 用于存储系统调用的错误信息,如果 errno 只是一个普通的全局变量,那么当多个线程同时调用了依赖 errno 的函数时,errno 中的值就可能会在被用户代码使用前被其他系统调用覆盖。而通过将 error 声明为线程局部变量,那么依赖 errno 的 API 无需做任何改动即可获得可重入能力,即线程安全。举个 C++ 的例子,我们在不同的线程访问同一个 thread_local 变量并修改、赋值,对于这个变量的修改不会影响到其他的线程:#include <iostream>
#include <string>
#include <thread>
#include <mutex>
thread_local unsigned int rage = 1;
std::mutex cout_mutex;
void increase_rage(const std::string& thread_name) {
++rage; // modifying outside a lock is okay; this is a thread-local variable
std::lock_guard<std::mutex> lock(cout_mutex);
std::cout << "Rage counter for " << thread_name << ": " << rage << '\n';
}
int main() {
std::thread a(increase_rage, "a"), b(increase_rage, "b");
a.join();
b.join();
{
std::lock_guard<std::mutex> lock(cout_mutex);
std::cout << "Rage counter for main: " << rage << '\n';
}
return 0;
}这些例子会输出:Rage counter for b: 2
Rage counter for a: 2
Rage counter for main: 1除了在解决函数可重入问题中可以使用线程局部变量之外,另一个常见的使用场景就是在使用线程作为服务端请求处理单元的模型中,我们也可以使用 thread_local 来存储请求的链路信息:因为每一个线程在同一时间只会处理一个请求,那么此时线程局部变量只需要对应当前这个请求链路,并且对这些数据的访问是线程安全的。使用 thread_local 存储类似的信息有几个好处:用户使用起来没有感知,不需要用户主动给框架传递请求链路信息参数;多个模块不会互相干扰,如果我们简单地将这些信息寄存在请求对象上,功能当然可以完成,但是如果多个模块都使用了类似的方法进行存储,这样十分容易出现冲突。AsyncLocalStorage与线程局部变量类似,Node.js 的 AsyncLocalStorage 提供了基于单线程的事件循环模型上的"异步局部变量"。AsyncContext 的 API 即是从 AsyncLocalStorage 之上发展而来的:class AsyncLocalStorage<T> {
constructor();
// 立刻执行 callback,并在 callback 执行期间设置异步局部变量值。
run<R>(store: T, callback: (...args: any[]) => R, ...args: any[]): R;
// 获取异步局部变量当前值
getStore(): T;
}
class AsyncResource {
// 快照当前的执行上下文异步局部变量全局状态。
constructor();
// 立刻执行 fn,并在 fn 执行期间将快照恢复为当前执行上下文异步局部变量全局状态。
runInAsyncScope<R>(fn: (...args: any[]) => R, thisArg, ...args: any[]): R;
}这些方法都可以与 AsyncContext 对应。目前 Async Context 提案还在 Stage 1 讨论阶段,后续提案的 API 可能会有所变更,不过可以预期的是整体操作不会有较大的变化。Noslate & WinterCGNoslate Aworker[8] 作为 Web 兼容运行时工作组(Web-Interoperable Runtimes CG[9] ) 的实现者之一。WinterCG 包含了如 Cloudflare、Deno 等 JavaScript 运行时产商。当前各个 JavaScript 运行时对于 AsyncContext 的需求是非常迫切的,许多客户都在催促着 Cloudflare、Deno 去实现类似的方案。因此,我们也在 WinterCG 中与 Cloudflare workerd、Deno 等提议了在 AsyncContext 提案进入 Stage 3 之前的实现路径。为了避免这些运行时在 AsyncContext 提案早期 API 未稳定的阶段就在生产环境中使用 AsyncContext API,我们通过 WinterCG 商议了指导性建议:AsyncLocalStorage 子集[10]。这个子集只包含了保证在未来几年中是能够符合 AsyncContext 提案演进路线、不会限制 AsyncContext 提案发展的 AsyncLocalStorage API 子集。Noslate Aworker 也会实现这个 API 子集:https://noslate-project.github.io/aworker/classes/aworker.AsyncLocalStorage.html。更多 ECMAScript 语言提案由贺师俊牵头,阿里巴巴前端标准化小组等多方参与组建的 JavaScript 中文兴趣小组(JSCIG,JavaScript Chinese Interest Group)在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学参与讨论:https://github.com/JSCIG/es-discuss/discussions。参考资料[1]Async Context 提案: https://github.com/tc39/proposal-async-context[2]React Context: https://reactjs.org/docs/context.html[3]OpenTelemetry: https://opentelemetry.io/[4]Scheduling APIs: https://github.com/WICG/scheduling-apis[5]难点: https://github.com/WICG/scheduling-apis/blob/main/misc/userspace-task-models.md#challenges-in-creating-a-unified-task-model[6]线程局部变量 (thread_local): https://zh.wikipedia.org/wiki/%E7%BA%BF%E7%A8%8B%E5%B1%80%E9%83%A8%E5%AD%98%E5%82%A8[7]errno: http://man7.org/linux/man-pages/man3/errno.3.html[8]Noslate Aworker: https://noslate.midwayjs.org/docs/noslate_workers/intro[9]Web-Interoperable Runtimes CG: https://wintercg.org/[10]AsyncLocalStorage 子集: https://github.com/wintercg/proposal-common-minimum-api/blob/main/asynclocalstorage.md
技术一号位的方法论《个人篇》——人成长的本质以及如何构建个人成长路线图
一、背景成长是每个职场人都绕不开的话题,同时也是贯穿每个人职业生涯的痛点。不论你工作了多久,不论你当前身处什么样的平台,不论你当前负责什么样的业务,在你的工作领域范围内,每天都有层出不穷的问题逼迫着你去学习很多东西,可是很多时候你会发现,学习并不等于成长。不同的阶段你的学习重点不同,体现你成长的东西也看似不同,但是它们背后却有着万变不离其宗的主线,只有抓住了这根主线,才算弄明白了成长的真谛。纵观职场众生相,那些看似可笑又无聊的坑,一遍又一遍地让历代职场人深陷其中:1.1 新人的选择困局初出茅庐新入职场的应届生,心怀理想,总想着在职场中披荆斩棘大显身手,却连接下来的路应该怎么走都想不清楚。总有一些“过来人”劝你说“选择大于努力”,可是当你手里拿着毕业证书脱离了老师和父母的指引站在校门外,面对着人生的岔路口左右为难时,没人会告诉你要怎么选,为什么应该这样选:是选择公有制的稳定、“清闲”还是选择私有制的挑战和“奋斗带来的丰厚的回报”?是选择做算法还是做工程?是选择做大数据还是做基础平台?选择做测试还是做技术支持?选择做项目管理还是产品经理?大多数人都是凭借着各种小道消息形成的认知偏见在幻想自己应该做什么,很少有人真的去科学认真地对待“职业生涯发展规划”这种“虚头巴脑的东西”:它只存在于每个职场人的求职面试里,每次跳槽面试的时候被新公司的HR拿出来“鞭尸”一番,而入职以后那些说好的职业生涯规划则变得与己无关:团队做什么事情、哪些是自己感兴趣的、哪些是和自己的职业生涯规划契合的统统都无所谓了,个人发展完全听天由命、任人摆布了。工作多年你才发现,最关心你的宏图伟略的人既不是你的主管也不是你自己,而是那个只有在终面阶段出现过一次的HR。别问为什么会这样,问就是 “5年、10年什么的时间太长,变化太多了,想了也没用还不如走一步看一步”。所以在职场打拼的各位,特别是初入职场的新人们,究竟该如何科学地构建自己的职业生涯发展路线图,从而能让你面对各种各样的职场变化时,有底气地对所有人说出“我命由我不由天”?1.2 高人才密度环境下普通员工的成长效率困局有的人毕业后进了“大厂”,背景光环带来的优越感甩开同届学生好几条街,可是进了某个团队以后才发现:核心技术团队留给新人的多数是一些看起来“边边角角”的事情;普通业务团队留给新人的则是沟通不完的业务需求和写不完的BUG。只有那些一入职就拿着顶级offer的天选之子才有可能打破这样的规律,也只有那些运气爆棚的人才可能遇到顶级项目从而一飞升天。顶级互联网大厂通过丰厚的物质待遇和平台机会吸收了全社会的人才,逐渐形成了高“人才密度”环境,这个环境里面的人个个身怀绝技、三人之中必有我师:那个看似事事亲力亲为拉你开会干活的一个小喽啰可能在上一家公司就是历经沧桑、呼风唤雨坐镇一方的军阀;一个看似人畜无害的新人可能是哪个实验室里手握多个顶会Paper的顶级人才,想要在这样的环境中占有一席之地,普通打工人既没有天赋可拼、又没有资历可比的情况下,目测只剩下了两个选择:要么更加卷,要么干脆躺。可是高人才密度环境配合特定的激励机制,选择躺的话,离淘汰就不远了;选择“卷”的话,却发现体力、脑力都跟不上。更尴尬的是在每年同学聚会的时候。自己虽然身在大厂却做着最普通的事情,与那些“当年不如自己的”入职中小厂的同学相比而言,发现对方现阶段的发展更好:在人才密度相对较低的环境中,对方得到的资源和重视程度更高,已经在带人做着自己看不懂的事情了。这就让人不禁感叹“跟对人、做嫡系”是真的香。在这样的内外双重压力下,普通业务开发人员究竟如何在“天赋党”和“经验党”以及“嫡系党”的三重竞争下脱颖而出?如何做着普通的业务需求,即使跟着团队频繁拥抱变化,也不会让自己虚度每一天?如何利用好科学的成长方法论来提高自己的成长效率,从而即便若干年后再次和当年的同学站在同样的竞争场上依然能够在各个能力维度上都不落人后?1.3 业务发展和个人成长的二元对立困局工作几年以后大多数业务开发CURD闭着眼睛都能操练得风生水起,可是很多时候业务方总是拿着“总裁需求”、“年度战役”这种尚方宝剑倒排工期,按着你的脑袋让你必须在几个特定的时间点完成工作。有的人在PD的需求提过来的时候,只需要学着“前辈”的样子假装老练地一连三问(目标用户群体是什么?带来的客户价值是什么?能给业务带来多少增量收益?)怼回去,就可以做壁上观,一边做着自己的“技术沉淀”一边冷眼看着产品经理们为了通过需求评审会而手忙脚乱地东拼西凑各种数据——看似在帮产品经理明确目标和打法帮助他们“成长”,可是实际上却充满了对业务“与己无关,高高挂起”的冷漠。这类人真正关心的永远是自己费尽心思造的轮子圆不圆,能给自己带来的票子多不多。直到业务增速降下来之后挤压掉所有人的成长空间、一年一晋升的神话不再上演的时候,才反应过来:过去那个看似高高在上、什么都懂、满嘴讲着“没有技术实现不了的需求”的自己其实是站在了“愚昧之巅”,现在开始滑入绝望之谷而无法自救,这个时候只能一边哀嚎“三年不能两升”是团队主管不公平是公司“药丸”,一边没头没脑地寻找着所谓的出路以求能够早日“上岸”。这类型员工在大厂内已然不是个例,他们面对的问题代表着广大普通业务开发面临的主要困境,只是选择突破困境的方式并不是最优解。更优的方案就是“把业务的发展和个人成长有机地结合起来”,可是这句充满了政治正确和辩证法的“废话”究竟怎么落地,从而让它变成名副其实的金玉良言?1.4 中年打工人低费效比引发的职场生涯终结困局大约3-4年前互联网行业刮起了“35岁职场终结”的妖风,连带着激起了很多年龄超过35岁的职场“老人”不停地在各种场合大声呼喊“我心态年轻所以我是年轻人”的浪潮,妄图通过发声表态来改变资本眼中的“年轻”的标准线,来维护自己的职场利益。可是在资本眼中“人”早已异化,沦为追求利润的工具,而且在资本家眼中,超过35岁的普通打工人的“原罪”其实并不是年龄,而是相对其他员工群体“费效比太低”(资本视角的逻辑,本人并不认同且本人也身在局中处于竭力自保的状态)。35岁的打工人基本上工作10年左右,假设做的工作内容没有太大差异,那么这个群体的员工投入成本要远远高于工作2-3年的员工群体,而价值产出却与其他群体没有太大差别。这种情况下,你是公司老板,你会怎么选?当你能给公司或者说资本提供的价值很大的情况下,没人会在意你的年龄,也没人会看你心态年轻不年轻。作为普通的互联网行业开发人员,想要在35岁的时候不被资本以各种借口砍掉,个人层面破局唯一的办法就是提升自己可以给组织创造的价值。(有的人会说改造资本家,改变其认知。这可能是另外一条可以走的路,但是事实上绝大多数的、普通的、被你们认为是资本家的人,本身既是资本的受益者,也是被资本“异化”后的“非人”。少数的企业家、民族资本家是例外,是利用资本造福社会的人,本质上其属性已经不是资本家了,而是另外一个阶级,这个话题可以在其他问题的分析中深入讨论,本文不再详聊)。为什么“35岁的P8及以上”可以在这股妖风中被豁免?因为“35岁的P8”背后的逻辑是:在业务线,这个人能带领团队负责某个甚至某几条业务线,能够依靠团队、平台创造出远超个人单打独斗带来的业务价值;在技术线,这个人的专业深度是构成公司对外竞争中技术壁垒、形成业务护城河的重要组成部分,所以他们即便年龄再大,也不需要为了年轻或是不年轻而纠结。所以对于普通人而言,想要应对资本对人的再一次的异化和压迫,关键在于如何确保自己工作10年之后,在拥有所谓的丰富经验、稳重的工作风格、正处于“当打之年”的情况下能够创造出高于其他人的价值——不论这个人是刚毕业、特能卷的新人,还是同样经验丰富、风格稳重的同龄人。所以,“35岁就被裁”或者“35岁不好找工作”的职业生涯的一大坎其实其背后本质问题就是“如何解决低效费比带来的职业生涯终结的困局”。问题的关键就在如何提升自己的效费比,而想要回答清楚这些问题,就要搞清楚你在公司里面创造价值的方式是什么、有哪些不同的创造价值的模式、你用的是哪一种、你创造价值的模式效率和价值产出是不是高于其他模式?在搞懂了这些问题之后,如何通过落地实践来逐步升级自己创造价值的模式就成了关键。1.5 管理者的PUA困局对于管理者群体而言,带领团队履行组织赋予的职责,实现组织的使命,通过业务将组织的愿景落地,完成所负责的业务价值的创造是工作的核心命题。而团队建设及人才培养则是该命题中非常重要的一个维度,而在“人才培养”这一命题中,“团队成员短板的提升”、“某方面的长处向个人核心能力的演变”、“员工核心优势在业务、组织发展过程中的价值创造和集体利益最大化”是几个同等重要的事情。但是不同风格、不同管理水平的管理者在这方面既面临着相同的挑战,又有各自基于自身认知和实践经验形成的看似“正确”的选择。对于相同的挑战而言,不论是刚做管理的管理新人,还是久经沙场的管理者,在围绕“培养团队成员成长”这个维度开展工作时,都会从另外一个角度遇到与团队成员自身成长过程中相同的问题,这些问题是“团队梯队建设与团队能力升级”过程中必然要面对并且不得不回答的问题(这四个问题其实就是上面四个小节中提到的核心问题,只是从管理者的视角从新发问):如何帮助团队新成员科学地构建他的职业生涯发展路线图?如何尽可能地提升团队成员的成长效率,从而使之在同龄人同层面的人才竞争中处于优势地位?如何平衡业务发展和人员成长,将二者有机地结合起来?如何帮助处于不同层次的团队成员完成其创造价值模式的升级?而面对这些共同的问题,有的管理者选择按部就班去把自己过去的经验对下属倾囊相授——不论是做师兄,还是做老师,还是做教练,都体现着职场的“传帮带”情谊。这类型管理者是理想主义者,不知道这类管理者在“长期在人员培养上投入很多精力却看不到太明显的变化”时是否依旧能够保住自己的赤子之心。有的管理者却永远“一心扑在业务上”,有合适的人就用,没有合适的人就招,招不进来就自己上,自己精力跟不上就凑合着用人。这类管理者对于“改变”别人已经不再抱有“不成熟的”、“理想化的”奢望了,这类型的Leader是实用主义者,深知改变一个人的难度有多大,也深知把业务做好了带来的钱有多少。至于团队能不能攻坚,业务遇到瓶颈了能不能突破,那是未来的事情,不是实用主义者应该管的事情。当然还有一些管理者,用合适的人选成就业务,用业务培养合适的人选,平衡了业务发展和人员发展的矛盾,打造的团队不仅可以处理日常业务需求,也能够在业务或技术进入深水区时保持足够的战斗力和创新精神,在业务上和技术上形成突破。原本不同的管理风格都是当事人自己的实践,然而在当前的互联网舆论环境中,管理者已经被异化为职场话题中的“大灰狼”,在由 “管理者——员工”构成的话题中,随着工作压力的增大、员工话语权的提升、新时代员工的意识的崛起,过去的很多复杂的矛盾被简化地认为是上级对下属的PUA,从而加大了主管帮助员工成长的难度——希望下属提升工作能力,下属却不配合。如何打破由低水平管理者PUA 引发的下属整体对上级的不信任而带来的团队建设工作的难度提升,是管理者在团队成员成长话题下面临的新的困局。(说白了就是主管怎么做才能不让下属觉得你在PUA,背后的命题就是如何打破由管理者低水平引发的下级不信任再到主管无法引导员工提升的负面循环,构建积极正面的循环)由上可知,不同的人,在不同的职业生涯阶段,都会面临不同的成长问题——成长是永远绕不开的一个话题。那么成长的本质究竟是什么?如何成长?如何高效率地成长?如何让自己的成长走在环境要求的前面?如何帮助别人成长?基于以上这些问题,本文作者接下来会讲解以下内容:先从“人的本质”入手(第二章节),再讲清楚“人的成长”的本质(第三章节),从而从最基础的本质出发,看怎么做才能让自己成长(第四章节)。 回答“成长”相关的一系列问题,期望读者能够根据本文的内容,结合自己的实际情况,明确未来自己的成长方向和路径,科学地定制自己的成长路线图。 (第五章节) 以管理者的视角给出如何进行团队成员培养的实践操作手册,以期能够给其他管理者提供一些可参考的内容。 (第五章节)友情提示:如果有读者嫌理论太长觉得不需要了解的,可以直接阅读第五章实践案例部分,在读第五章的过程中,如果读者好奇作者在实践案例中为何会这样做、如何得出这些办法的,可以再回头仔细阅读第一章到第四章中的理论讲解。二、人的本质2.1 人的本质的定义从哲学的角度来讲,“人的成长”实际上是在谈论“人”这一客观事物在某些维度上的某种积极的发展趋势,因此在谈论“人的成长的本质”之前,我们需要先搞清楚“人的本质”是什么,然后才能对“成长是人的哪些维度的什么样的变化趋势”得出合理的结论。在马克思主义哲学中,人的本质是重要的命题,我们看下相关领域的专业论文是如何分析人的本质的:第一,人的本质是人自身。…… 马克思关于人的本质的论述说明人的本质不是宗教,而是现实的人,他超越了德国哲学抽象的人以及费尔巴哈丢掉人的社会本质而仅仅强调人的自然本质的观点,而以人的“国家特质”“社会本质”来说明人,把人从神的统治和奴役下解放出来,把人的本质还原给人……第二,人的本质是人的需要。……总之,马克思在多部著作中阐述:人的需要的产生既受现实状况制约又有主观能动性;人的需要的满足过程是人认识、确证和实现自己的社会本质的过程(本文作者解释:这个过程即是“实践”);人的需要的对象,是表现、确证他的本质的重要对象,人的需要充分地表现了人的社会性、历史性和过程性,也就充分地表现了人的本质。第三,人的本质是自由的有意识的劳动。第四,马克思认为,“人的本质不是单个人所固有的抽象物,在其现实性上,它是一切社会关系的总和”。这一观点强调了人的本质的现实性、社会性和受制约性……《张国安:马克思关于人的本质的四重含义》来源:《甘肃社会科学》2015年第6期(原文标题为《马克思关于人的本质的四重含义及其现实意义》)链接 http://myy.cass.cn/mkszyjbyl/201607/t20160702_3096276.shtml从上面的分析可知,人的本质分可以分为两个维度,一个是人的物质性,一个是人的社会性,如下图所示:图1 人的本质总结下来,可以发现:人的所有的行为,不是在满足自己的需求,就是在满足别人的需求。满足自己的需求的过程,可以粗略总结为实现个人价值的过程;而满足别人的需求,就是通过劳动(即实践)创造价值并完成价值的交换的过程。我们可以从人的本质中提炼出几个非常非常重要的过程:“由认知指导实践,实践完善认知的过程”、“创造价值的过程”、“价值流转的过程”。对这几个过程建立起最基本的、正确的、全面的认知,是理解人的成长的必要条件。很多人围绕成长这个话题写过很多文章,分享了他们对成长的思考,但是这些分享很多都只是零零散散地谈到了作者自己认为的重点,并没有给出科学的定义,也没有系统地拆解清楚成长的过程,从而只能向读者提供认知上的参考但是无法直接指引大多数人进行实践,仍然需要读者自己去“悟”。因此接下来的章节,会对几个关键过程进行分析探讨,希望能够帮助读者建立起一定的认知,然后在此认知的基础上帮助大家理解成长的本质,最终通过文章的引导,来帮助读者完成个人成长路线图的确定以及落地实践。2.2 由“人的本质”引出的几个关键过程1. 认知指引实践,实践完善认知在毛泽东主席的《实践论》中详细地阐述了理论和实践的辩证关系。我们知道“理论”就是人的理性的、全面的、客观的认知,与此对立的是人天然的感性的、片面的、主观的认知。认知可以指引人的实践活动,同时实践活动也会补充、完善人的认知。再加上理性的分析、思考、总结,就可能把一般的实践经验升级为具有一定适用范围的理论,这就是认知和实践的辩证关系。我们之前在其他文章中阐述了实践的本质和过程,而今天的文章则更加强调“认知本身以及认知对实践的影响”。谈到“认知”,不论是在哲学层面还是在心理学层面,都是一个非常大的话题,受限于文章篇幅,我们直接谈论认知的分层模型,不再详细地展开分析认知的本质了。具体分层模型如下:图2 认知分层模型如上图所示,这个模型的每一层内容具体含义如下(由低到高):感觉:由人的生物学基础能力组成的对外部客观事物感知的能力。不再做过多的解释了。知觉:心理学上的定义: “知觉是一系列组织并解释外界客体和事件的产生的感觉信息的加工过程。换句话说,知觉是客观事物直接作用于感官而在头脑中产生的对事物整体的认识。——引用自百度百科”。它依托于感觉,同时也包含着更复杂的记忆、思考、语言活动等等,比感觉更综合更全面。例如,火很烫,就属于感觉,而火会把其他可燃物点燃就是知觉。经验:人做事情的经历经过总结以后所具备的解决同类型问题的能力。与知觉相比,经验包含了对实践的总结,但还未上升到理论的高度。比如,火烧会感觉疼,并且对人造成伤害,所以就要远离火,这是知觉;用火可以把水烧开来煮饭、煮面、煮其他东西,就是基于实践总结后的经验。知识:所有被公认的、经过检验是对的,在一定范围内适用的认知都是知识,它比经验更全面更完善、更全面。例如,火可以用来烧开水煮东西是经验,而不同可燃物的燃点、火的温度范围、火在化学层面的反应原理就是知识。方法论:顾名思义,指导人们认知、做事情的理论。与“静态的“知识相比,方法论更侧重于指导人们解决问题,会涉及对问题阶段、任务、工具、方法技巧的论述。例如,可燃物的燃点是知识,而利用不同可燃物的燃点和燃烧特性来制造不同目的的工具,整个制造过程的技术工艺等的描述,就是方法论。理论:”理论是指人们对自然、社会现象,按照已知的知识或者认知,经由一般化与演绎推理等方法,进行合乎逻辑的推论性总结”——引用自百度百科。例如制造烟花的工艺是方法论,那么不同化学元素在不同温度下的火焰颜色、不同化学元素的配比和相关研究就是理论。科学:”科学,是建立在可检验的解释和对客观事物的形式、组织等进行预测的有序知识系统,是已经系统化和公式化了的知识。“——引用自百度百科。科学比理论更加体系化系统化,属于更高范畴的概念。例如我们刚才的例子中关于化学元素的火焰颜色的研究及应用是理论,其实这种理论从属于自然科学下的化学科学。哲学:“哲学是世界观的理论形态,或者说是系统化、理论化的世界观”。“与科学相比,科学是研究客观对象,而不研究作为研究主体的人与对象之间的关系。科学表现为某一个领域的某种具体的知识,而哲学则表现为一种智慧。这种智慧与科学知识的不同之处就在于,它以科学知识为基础,但它又不是简单的科学知识的相加,而是对科学知识中蕴含的普遍规律和意义的解释。这就是从具体的科学知识到普遍的哲学智慧的升华,或者说,转“识”成“智”。“——引用自《马克思主义哲学原理》陈先达 杨耕 编著。还是以研究元素燃烧为例,属于自然科学的化学分支,而人为什么要研究化学元素的燃烧、人研究元素燃烧火焰的意义、研究化学元素燃烧对人类的影响等,就属于哲学的范畴:它包含了人对客观世界的感知和看法,也包含了人对其他人的看法。不同认知层次中的这些名词,在心理学、认知学甚至哲学中都有非常严肃、详尽的研究,我们这里只做简单的摘抄和收录,方便读者建立起相关的概念认知,以及感知到不同认知层次的区别,更具体的理论分析就不再一一展开了,感兴趣的读者可以自行阅读相关理论文献。接下来我们基于认知模型来了解一下它的特性。首先我们要认识到认知在人的决策、实践过程中的复合性。就是说并不是只有单一某个层次在发挥作用,而是多层次同时发挥作用。不同层次的认知本身构成不同,所以在决策中起到的作用也不同,从而对实践的指引效果也不同。在人的决策过程中,哪个层次发挥着主导作用,就说明这个人的认知主要在哪个层次。其次,我们要认识到不同层次的认知在指导实践时的适用性和局限性。不同层次的认知应对不同的事物时,存在着优势和劣势,甚至会决定一个人是否能够良好地应对他所面临的问题。越是低水平的认知,能解决的问题维度也越少、维度越单一;而越是复杂的事物,越需要与之匹配的更高阶的认知。比如牙牙学语的幼童可以凭借感觉避开炉火的灼烧,但是无法解决“把饭做熟”的问题;网络上的很多键盘侠被称为“二极管”,就是因为在面对很多复杂的事物时简单粗暴地给出非此即彼的结论,并且热衷于言论暴力和站队倾轧,都是由认知不足引起的不当言行。再比如“经验”这个层次,它的优势就是解决同类问题。有些人做事依靠经验,所以只能做自己做过的事情才有把握做好,真正做一些开创性的事情时这类人解决不了的,原因就是经验只能解决个人经历过的事物和其他同类型事物的共性问题,而无法解决个性的问题。因此,只靠“经验”做事的劣势就比较明显了:输出不够稳定,有时能把事情做好,有时会坏事,继而会影响周围人对这类人的信心。所以,想要彻底解决某个问题,就得理论加经验加实践共同发挥作用,做到实事求是(关于经验和理论的辩证关系,以及很多人对这二者的认知误区导致的各种问题,后续会单独开一篇文章详细讨论,这里不再展开了)。也正是因为不同层次认知存在着这样的限制性,所以一个人想要在某个领域更好地进行实践,就需要不断地提升认知层次,这是一个人要提升个人认知的内在动力。再次,我们要认识到不同层次的认知之间存在着层层进化、相互依存的辩证关系,这是“认知可以被科学地提升”的理论基础。自下而上地看,没有“感觉”来支撑人对客观世界的基本认知,就无从谈起人基于这些基本信息进行分析总结形成“知觉”,没有“知觉”,也就无从谈起基于场景和事物共性的“经验”,没有对“经验”进行总结归纳、对各种现象进行科学研究,那么可以被广泛接纳的知识就不会形成也不会传播。没有对客观事物、现象的抽象的分析和探索,也就不会有哲学。我们可以利用前人的研究和结论快速地、针对性地建立起目标层次的认知,但同时也不能忽略其他层面的认知对目标层面认知的支撑,所以“认知的提升”一定是科学的、有目标的、体系化的,而不是单一的、一蹴而就的。第四,我们要重视不同层次的认知之间的差异性,要看到不同层面的认知提升到下一个层次的路径。对于技术人员而言,阅读单一领域的大量的专业书籍来增加知识量,并不能“提升认知”,这种方式更多的只是加深了对该领域的认知或者“拓宽了认知维度”。想要提升认知,要结合认知的分层模型去有目的地阅读与下一个层次相关的书籍,并且需要将静态的知识与实际的实践行动结合起来,从而让该认知层次的知识在自己的决策中发挥主导作用,做到知行合一,然后才能完成个人的认知提升。在掌握这个模型之后,就可以结合自己的实际情况,来构建自己的认知模型,在明确自己的认知模型之后,就能知道提升自己的认知需要超哪个方面努力了,我们会在下一个章节中结合认知模型重点讲述如何提升认知,并且指出大家在学习过程中存在的一些误区以及正确的方式是什么样的。2. 创造价值的过程一般情况下,创造价值的过程可以理解为“劳动”的过程,价值也可以理解为劳动的产物,用马克思主义哲学来讲,“价值是凝结在商品中无差别的人类劳动”。同样受限于篇幅,我们不再详细讲解劳动、价值的本质,而是从宏观层面将创造价值的过程作为整体来做研究和分析。以大家日常工作为例,不同角色的人,对企业而言创造价值的模式不一样,创造的价值的大小也不同,因此不同角色获得的各种回报的模式也不一样,存在明显的不同的上下限。这一点在《技术一号位的方法论:》文章中有比较完整的论述,这里就不再展开了。本文作者结合实际工作中能够观察到的一些角色总结了一个“创造价值的模式”的模型,如下图所示:图3 创造价值的模式首先,我们要认识到,在一般情况下(不讨论特殊案例和场景)不同的价值创造模式所产出的价值的效率和规模不同。依靠个人的基础生物能力来创造价值换取回报(俗称做体力活),单位时间内创造的价值量低,创造价值总量不大,因此可以获取的回报的上限较低。而依托平台资源来创造价值,则创造的价值规模更大,创造价值的效率与单人相比更高,因此可以获取的回报更多,上限更高。其次,我们要发现不同的创造价值的模式之间的层层递进,相互依赖的关系。更高层次的价值创造模式脱离了低层次的价值创造模式是无法实现落地的,比如资本家没有工人就无法完成价值的创造和剩余价值的剥削;纯资本运作的业务模式脱离了实际价值物的支撑也无法运转;而随着低层次的价值创造模式的不断积累和量变,就会发生质变,出现高层次的价值创造模式——比如在宏观层面,劳动密集型产业逐步过渡到技术密集型产业甚至发展为资本密集型产业,就是因为生产过程中随着技术的引入逐步替代人工,转变为了新的产业模式;再比如个人层面,从单人工作到管理多人协作,就是个体在工作中,随着负责的事情更多、实践经验更丰富,从而培养出多维度综合技能,从而完成了个人创造价值的模式的转变。这个辩证关系就为我们提供了“转变创造价值的模式”的理论基础。再次,要认识到个人能力、品质和个人创造价值模式之间的辩证关系。个人能力、品质与其创造价值的模式息息相关,具体而言,个人能力决定了可以用什么模式创造价值,而品质决定了这个价值是否是真的“价值”,也决定了得到的回报是“正向的”还是“负向的”。个人知识有限、生存环境资源匮乏,就容易出现“穷山恶水出刁民”的现象,因为这样的群体可以用来创造价值的工具只有他自己的身体、头脑:不论是拦路抢钱也好(主要依靠四肢、体力),还是使奸耍诈骗人钱财也罢(主要依靠大脑思维),都是因为这样的个体创造价值的手段有限,并且个人品质有问题导致的。如果个人品质好,即便是生存环境资源匮乏、生活水平不高,也会出现“仗义每多屠狗辈”的客观现象。而随着个人能力的提升,创造价值的手段变多、可以创造价值的模式也就会发生变化。这个辩证关系指出了“转变创造价值模式”的途径,即可以通过提升能力或改变能力结构来转变创造价值的模式,与此同时,也指出了除了能力之外,个人品质是基础,决定了实践创造的是“价值”还是“灾难”,因此在转变模式的同时需要注重个人品质的提升。最后,要认识到不同的创造价值的模式背后所蕴含的问题规模、复杂度不同,所以开展的难易程度也不同,因此对人的要求也不同。低层次的价值创造模式——启动门槛低,对人要求低;而高层次的创造价值的模式对个人能力、品质、所掌握的资源都有较高要求。如果一个人在职场收入见顶,进入了职业生涯的瓶颈期,那么继续在原有的模式中投入更大的精力和资源可能并不是最合适的选择,可能最合适的选择是提升能力、转变认知,进行“创造价值的模式”的升级。当然,除了事关成长的创造价值的模式升级之外,读者还要理性、客观、全面地看待这个模型针对个体时表现出的差异性。它与个人当前所处的职业生涯阶段、个人的性格品质、个人的认知和办事能力都息息相关,有些人可能会凭借自己的努力能够实现模式的升级,但是也要认识到世界上还有很多人或许一辈子只能以一种较低层次的模式来创造价值获取极少的回报;同时也要认识到大多数人创造价值的模式是固定的并且很难再升级到下一个层次的,也就是说,当一个个体创造价值的方式固化并且其价值量逐步见顶的情况下,该怎么办,突破口在哪里,都是需要大家结合自己的实际情况思考清楚。这里作者可以给与的建议是,对于一个人而言,需要成长的维度很多,某个维度见顶之后,可以从其他维度寻找新的方向,可以是个人爱好,也可以是家庭,也可以是社会责任,都无高低贵贱之分,只在于自己的本心是什么,本心清明而坚定,那么就会少却诸多烦恼和内耗。3. 价值流转的过程在“人的本质”中所涉及的第三个过程,就是价值流转的过程,这个过程同样在马克思主义哲学中有大量的研究和描述,实际上属于政治经济学的范畴,同时在不同的学科(经济学、会计学等等)中也有很多专门的研究,我们这里不再做更详细的论述,只结合价值流转模型来探讨日常工作中的几种场景,从而让读者从宏观的层面看到自己的日常工作实际上是由哪些环节组成的,如下图所示:图4 价值流转过程在解释价值流转过程之前,我们有必要把“价值”相关的核心话题讲清楚。关于价值本身,首先,要认识到个体在组织内提供价值的内在原理是什么,同时也要认知到个体需要提供什么样的价值。一般情况下,人对外提供的价值来源于他在群体中所扮演的角色的要求。例如,在信息产业公司中,研发人员的价值就是把业务需求实现并确保系统运转良好,产品经理的价值就是把业务包装成好的产品满足用户的需求,业务运营的价值就是完成产品价值与客户的链接和传递,让更多的客户找到满足需要的产品,和其他角色协作让业务持续良好地运转下去。这些价值都是由其岗位或角色决定的。其次,我们要认识到,由于个人在组织内所扮演的角色存在多样性,因此就要求个人创造的价值需要具备多样性。一个人在组织内的角色并不是单一的,而是多维度的、多层次的,所以需要提供的价值也不是单一的,而是叠加的。如果个人对自己在组织内所扮演的角色认知有限,那么他所应该创造的价值就有缺失,就会影响组织、环境对个人的整体评价,这就是为什么我们常常说,某某是个多面手啥都能做(个人综合能力强),而某某某只擅长做某方面的事情(个人能力单一)。这一点就为我们提供了提升个人价值的一个途径——全面梳理自己在各种场景中所扮演的角色,针对不同的角色要求提升能力,产出对应的符合一定标准的价值,从而提升个人整体价值。最后,我们要看到组织内的角色和其基本能力的要求之间的辩证关系。这就要求大家能够识别自己在工作中扮演了哪些角色,是否具备了满足这些角色要求的能力;同时也要认识到随着个人能力的变化、业务发展的变化、组织发展的实际情况,个人扮演的角色会随之变迁,那么就要求个人能力也要随之匹配,否则就会出现角色变了但是能力没跟上,导致个人无法创造角色对应的价值的情况出现。在聊过“价值”的几个话题之后,就要分析价值的流转过程了。关于价值流转的过程,我们既要认识到在自己实际工作中,哪些工作内容和“价值流转的过程”中哪些环节对应,也要知道这个过程本身每个环节对价值的放大作用或抑制作用。我们先要分析自己的价值流转过程是什么样的:作为业务研发人员,要结合自己创造价值的模式、自己在组织内扮演的角色,从宏观的视角来对自己的日常工作做复盘分析——分析需求、做好方案、写好代码是创造价值的过程;个人积累的技术能力、行业经验是你在找工作或组织调动的依据,你需要让潜在的“雇主”看到你这些方面的积累和造诣,就要多做分享、多参加技术论坛,这就是“个人营销的过程”;与上级或雇主建立起良好的信任关系,在新的技术产品或技术方案中取得上级的认可和授权从而获得资源支持,就是“价值销售的过程”;把承诺的事情做好并产出价值,满足上级或雇主的预期,就是“价值交付的过程”;最终能够通过个人的不断努力确保这个链路循环运转,不断扩大自己的职责、创造更多价值,就是“营收并确保价值链持续运转的过程”。在这个分析结果基础之上,要看自己哪些环节没有做好,甚至根本没有意识去做。比如有些研发人员说:“我只是表达能力不行,某某某就是比我会说,技术跟我比起来差很多,但是没办法领导就是器重他”——这样的例子在工作中很常见,小小一个场景短短一句话,反应出来了非常多的问题,我们只从“表达能力不行但是技术能力强”的群体来说,最大的问题就是“营销”环节出了问题,不能把自己在组织内的价值传递出去、传递到对的人那里,所以与其他人相比,无法获得上级更多的关注,这就是典型的因为营销环节的缺失导致的个人价值被压缩。当然,这个场景里面还有那个“技术不行、看起来只会说”的人(营销能力放大了个人价值产出的评价,但价值本身需要加强),以及他们的管理者(需要具有恰当识别团队成员产出的判断力,并针对性地给存在问题的员工指出问题及解决办法,协助这类员工成长),以及他们三个所处的组织,每一方都有对应的问题,这里就不再展开讨论了,感兴趣的读者可以找作者私聊。总之,价值流转的过程涉及到了多方角色,不同的环节会影响价值向客户传递的效率,影响价值在客户视角中的体量,随之也就会影响客户的决策,最终客户决策带来的积极的或消极的影响会作用到价值创造者本身。科学合理的认知价值和价值流转的过程,对完善个人价值流转链路、实际甚至放大对外传递个人价值有非常积极的帮助。三、“人的成长”的本质我们了解了人的本质和人的本质中非常重要的“三个过程”以后,就可以围绕着这三个过程来针对性地分析人的成长的本质是什么,人的成长的衡量指标是什么,想要成长必须满足的前提条件是什么,人成长的客观基础是什么,人成长以后会有什么样的现象。3.1 “人的成长”的本质3.1.1 “人的成长”的本质定义由认知提升所指导的实践过程所创造的价值的提升,是人的成长的本质。 3.1.2 人的价值,是判定人是否成长的唯一指标在马克思主义哲学中,人的价值分为个人价值和社会价值。人的个人价值是通过实践满足自己的需求而产生的,人的社会价值是通过实践满足他人的需求而产生的。如下图所示为人的价值模型:图5 人的价值模型限于篇幅,我们不再展开讨论个人价值和社会价值的关系,大家只需知道二者是不可分割,相互支撑的,这部分命题属于“组织”方面的基础理论,我们会在《技术一号位的方法论——组织篇》中相关文章中做出更详细的讨论。在了解人的价值模型之后,我们还需要了解“人的价值”的“评价模型”。人的价值模型决定了人的价值的评价维度,构成了“人的价值”的评价模型,如下图所示: 图6 人的价值评价模型“人的价值”的评价,第一个维度是自我评价,对应个人价值;另外一个维度是外界评价,对应社会价值。个人评价与外界评价共同构成了人的价值的评价,二者缺一不可。个人价值的大小的评价,取决于个人价值创造的过程及自我认知;社会价值的大小的评价,取决于价值流转的过程及群体对该个体的认知。这一点为“从哪些方面来提升人的价值以及评价”提供了理论依据并且指明了提升方法:在客观方面,既要改善创造价值的过程,也要改善价值流转的过程;在主观方面,既要提升自己对自己的认知,也要想办法通过科学合理的方式提升别人对自己的认知;从客观和主观两个方面共同发力,从而完成“人的价值”的评价结果的提升。基于以上分析可知,我们讨论一个人的价值的大小时,也需要综合两个维度的评价结果来确定。一个人如果没有创造任何个人价值,他自己就无法判断自己的个人价值有多大,也就无法评判他自己的个人价值是否变大或变小,也就无法从个体内在本质上认同自己是否成长。同理,一个人如果没有创造任何社会价值,那么他所生活的社会群体就无法感知他的存在,继而就无法评判他所创造的价值是变大了还是变小了,就无法在客观现象上让群体感知到他是否成长。所以,想要知道一个人是否成长,不论从内在还是外在,都需要基于对人的价值的评价来确定,所以说人的价值是判定他是否成长的唯一指标。 3.1.3 “认知提升”是个人成长的前提认知提升是个人成长的前提,一方面强调了“在一般情况下,认知变化是个人实践结果变化的前提”。认知没有提升,实践过程变化不大,一般情况下实践产出的价值变化也不大,因此产出的价值在面对个人评价和外界评价时,也不会产生质的变化。即便有特殊情况,个人认知没有提升但是实践产生的价值变大,那也要实事求是地进行分析价值组成,一般也都是由其他外界因素影响带来的价值增量,与个人本身无关。建立起这个认知,对于个人合理评价自身价值、认清个人所处的情况非常有帮助。职场中很多人没有这个概念,把平台、额外的资源带来的价值增量误以为是个人创造的,表面上看起来每年工资、职务都在上升但是忽略了个人认知的提升,导致的结果就是在某个时间点发生职业生涯变动时,就会发现个人和N年前的自己相比毫无变化,而所处的就业环境已经对自己非常不友好了。这其实就是大家常说的:需要在成长的路上褪去平台带来的光环来看待自己,抛开各方干扰真正静下来审视自己才能突破。认知提升是个人成长的前提,另外一方面强调了认知提升并不是成长本身,仅仅是个人成长的第一步。我们首先要明白,只有知识量的增长不是认知提升。很多人会阅读大量的书籍来制造个人成长的假象,但是在实际生活中遇到困难时无法很好地处理,只能一边留下“中年人的崩溃瞬间”的身影,一边怀疑自己并发出对自己的灵魂拷问:我读书到底有什么用?很多人都在说“知识改变命运”,但是这句话事实上是:知识带来的实践的变化才能改变命运。其次,只有认知提升却没有实践,就无法创造价值,就没有成长。很多人会觉得自己上知天文下晓地理,但是现实生活并不如意,所以就用“怀才不遇”来安慰自己、敌视环境,实际上就是因为没有对“个人认知和价值的关系”形成正确的认知才导致的。这样的例子在历史上有很多,诗仙李白仕途不顺,不是因为他诗歌写的不好、对写诗认知不够,而是因为官场对他的价值的评价并不在于诗歌而在于他的所言所为是否符合官僚体系的利益,如果真的一心想在仕途发展,那就得多想想组织内的生产关系和利益分配如何处理,在提升了这方面的认知以后再做出实际行动,才可能被官僚体系接纳。但是李白自己的本心是怎样的,“安能摧眉折腰事权贵,使我不得开心颜”已经说得明明白白的,他是怎么选的,“钟鼓馔玉不足贵,但愿长醉不愿醒”也说的明明白白。所以要认清“认知与价值”之间的关系,也就明白了“认知提升和成长”的关系,也就知道了认知提升仅仅是成长的第一步。 3.1.4 由“认知提升”指引实践,创造的“价值”,是个人成长的客观基础由前文的“人的价值”的评价模型可知,自我评价由个人完成,外界评价由个体所在的群体完成。这就意味着“人的价值”的评价过程,必然会引入评价者的主观判断。这些主观判断中,大的方面会受社会文化、地区习俗影响,小的方面会受评价者的个人认知、群体利益影响。不论个人评价还是外界评价,都不单单是客观事实,而是基于客观事实所形成的“人的主观认知”与客观事实本身的组合体。没有实践创造价值这个事实过程,就不能让个体形成自我价值的判断;没有价值流转这个过程,也就不能让群体对个体形成社会价值的主观判断。所以说,实践创造的价值,是评价人的客观基础,那么由“认知升级”所指引的实践创造价值,就是“人成长”的客观基础,是反应一个人在实践过程中,与过去的自己相比有没有创造价值增量的客观事实。 3.1.5 实践创造的“价值的提升”,是“人成长的本质”对外呈现出的现象我们在之前的篇幅中大量地讨论了人、人的本质、人的价值模型等,没有展开讨论 “成长” 这两个字,我们这里简单下个定义:“成长,即客观事物的某个属性,在一定的时间尺度上,发生有利于客观事物生存和发展的变化或呈现出这样的趋势”。所以“人的成长”所体现出来的就是“人的价值”在一定时间尺度上变得更大。想要判断价值是否变大,一方面要确立好评判的标准。很多人觉得自己有成长,细问起来今年的自己和去年的自己有何区别,却无法说清楚——这就是因为个人对自己想要成长的维度不确定,对成长的标准没有设定,对是否有成长也没有科学合理的判断方法,一切都是在凭感觉下结论,这些问题会让成长过程变缓慢甚至没有成长,最终导致无法应对环境的要求。还有些人已经是人中龙凤,每天做事也是殚精竭虑,但是总觉得自己不够好,总觉得实际结果不及理想预期,一味的长期坚持完美主义,就导致个人精神健康和生理健康都受到非常负面的影响——这也是因为个人价值判断标准设置不合理,究其根源在于认知出了问题导致的。想要判断价值是否变大,另一方面需要有科学合理的评判方法。很多人骄傲自大,就是没有合理综合环境对他的评价,在认知上过高地评估了自己创造的价值,可以多和周围的人沟通对齐来解决;很多人周围的人说他不好,就会产生非常大的负面情绪继而产生自我内耗而无法把事情做好,本质上也是因为过重的、不分对错地看待外界评价,而没有做好个人自我评价的结果。科学合理的评判一个人价值的大小的办法,就是锚定个人实践所创造的价值,在此基础上平衡好自我评价和外界评价,避免妄自尊大或者妄自菲薄。不论是设定合理的标准,还是建立科学合理的评判方法,都不能忘记要回归个人价值的本质,一个人是不是有价值,既不是自己说了算,也不是别人说了算,而是要综合地、客观地去看。做到这一点,就能够让人在遇到艰难险阻的时候摒弃来自他人的干扰的噪音坚持自己的理想,也能让人在遇到“不可为之事”时听取别人的建议完成自我的成长。 3.2 “人的成长”的现象与假象在某个环境中,在某个维度下,在一定时间尺度上,个人对自己的实践所产生的结果与过去相比更加认可,个人所在的群体也对个人所创造的价值更加认可,就是成长的现象。成长的现象多种多样,有的是真正的成长所带来的外界可感知的变化,有的则是假象。我们需要结合上面内容所提到模型和认知来判断自己是否真的成长了。有人想成长,读了很多书,知识储备变大,专业技能提升,却对公司业务没有任何实际帮助,在公司眼中这种成长是不是成长?有人借力平台随着业务发展乘风破浪,收入年年攀升,脱离公司的环境再看,究竟有没有成长?有人趁着年轻打拼多年所获颇丰,人到中年后却突然佛系起来,他是否是成长了?这些问题其实都没有正确答案,只有当事人自己知道自己想什么,知道自己做了什么,而究竟做的怎么样,不仅需要交给周围环境来检验,还需要交给时间来检验才能有所定论。所以最终我们再从具体现象回归到哲学,人的成长的现象最终究竟是真是假,还是要看其所为(客观事实),也要看其本心(主观认知)。四、如何让自己成长按照成长的本质来看,让自己成长,首先要提升认知,再用提升以后的认知来指导实践,最后确保用实践创造价值的变大,来让自己和周围环境感知到自身的成长。本章节就展开讨论具体让自己成长的方法论,读者可以参考这个办法,列出自己的清单。4.1 提升认知提升认知是个人成长迈出的第一步。4.1.1 树立科学的成长观提升认知之前,首先要树立科学的成长观,只有建立起科学的成长观,才能确保不走歧途,迎难而上。1. 成长是一个痛苦的事情。 不论是在认知提升之初需要阅读很多对应的专业知识,还是在认知提升过程中的自我怀疑、自我否定;也不论是长期有意地、艰难地坚持用新的认知来指导自己做事,还是让做出的结果符合自己所在的群体的要求:以上每一个阶段在客观上和主观上都充满挑战。很多人连认真阅读本文了解基本原理的耐心都没有,只期望上苍有根金手指随便点拨自己一下,把结论直接喂到嘴里,自己就从里到外换了一个人,就能完成个人成长,可实际上这只是黄粱一梦。不了解成长的本质,不构建基础认知,成长只能是听天由命的概率事件。我们知道认知提升往往伴随着旧的认知的推翻,很多人来说,这一点不亚于从精神上否定一个人的全部。因为正常人都很难承认自己所秉持的一些看法是有问题的,受这个看法影响的很多事情都做错了——这其实就是“打碎旧我建立新我”的痛苦过程。用新的认知指引做事也是一个无法立刻得到正反馈的过程,甚至其中遇到各种挫折时的怀疑和纠结,都会一次又一次地撞击着你的意志力劝说你放弃,仿佛就是在你好不容易抹杀旧的自己重树新的自己时,新的自己却立一次碎一次,这也是成长路上的“断头台”。当你好不容易做事得到了一些结果,却一时无法被外界认可,同时又面临着外部人情世故的压力时,这时你就会发现,很多时候满足别人的需求原来并不是简简单单的做一个事情,而是要让“别人眼中的我”和“自己心中的我”冲突搏斗一番才可以。所以想要成长的人既要知道成长是痛苦的,做好心理准备,也要常常审视自己想要什么样的成长:只有你自己想要的那种成长,才能让这个痛苦的过程把痛苦中所包含的来自内心深处的丝丝甘甜与喜悦在最后汇聚成甘之如饴的结果;而对于那些你不想要的成长,这个痛苦的过程会在外界强制摧残本我的时候激起的敌意和愤恨,最终让你吞下的一定是“身不由己”的苦果。想要成长、渴望成长的人要做好全方位迎接痛苦的心理准备,当然也别忘了享受成长之后突破自我带来的由衷的喜悦。2. 成长也是一个长时间尺度的事情。妄想短期就能见效的都不会得到真正的成长。成长不是一蹴而就的,从新的认知建立、再经过多轮次的认知指导实践、再到价值的传递并获得反馈,每一个环节都需要时间。“21天C++从入门到精通”,本质上只是用21天完成了知识点的记忆过程,而非成长的过程。只有从“21天C++从入门到精通”过度到“颈椎病的防护与治理”,最后再升级到“论生物遗传和生活习惯对谢顶的影响”,那么这个过程里面才可能会暗含着令人唏嘘的成长。3. 成长是一个人的事情,却也不是一个人的事情,但是最终归根到底它还是一个人的事情。 个人主观上无意成长,别人也当然无能为力鞭长莫及。只有自己主观上想要发生改变,才可能得到周围人的帮助,正所谓自救者天救——自己的实践结合他人的帮助才是合理的成长方式。但是如果自己想要成长了,就觉得别人都应该帮忙,都应该体谅,那就又陷入了另一个误区。很多初入职场的人,总想着团队里面的大神带自己起飞,却不知道没有在专业领域相应的积累沉淀、没有通过一次次协作产生的信任、没有在关键事件中体现出一飞冲天的潜力,即使再大的神,也没理由、不可能“带你飞”。职场人要明白,成长是自己的事,也是自己主管的事,但是归根到底还是自己的事。4. 成长不等于收到更大的物质回报。 我们聊了成长的本质,没有聊成长的目的,就是因为对个体而言,“成长的目的”千差万别,篇幅有限不宜深入讨论。但是在宏观层面,成长的内在动因就是因为客观环境对人的限制而激发出人的一种主观期望。所以,不论具体某个人的成长的目的是什么,在哲学层面最终都会回归到人的需求上来。而人的需求分为物质上的、精神上的,马斯洛需求模型给出了更细粒度的划分,这里不再赘述。所以说,如果“把成长简单地等于更大的物质回报”,其实不但没有触碰到成长的本质,更没有完成个人“自我需求”的升级。我们允许有的人以物质回报作为很多事情的出发点,但是我们更期望那些有能力的人不仅仅停留在物质需求的满足上,而是升级个人需求的同时完成社会价值的创造。很多时候个人成长不仅关乎于己,也关乎大义——“为天地立心,为生民立命,为往圣继绝学,为万世开太平”,其实“横渠四句”就是藏在每个国人内心深处的浪漫主义家国情怀,同时也构成了整个中华文明奋进自强的底色。所以我们也希望那些掌握了更多资源之人在个人成长的同时勿忘民族重任、世界大同的鸿鹄之志。4.1.2 重新构建认知体系1. 梳理自己的认知体系建立科学的成长观的同时,要参考上文讲述的认知模型来着手梳理自己的认知体系,可以围绕任意的命题画出自己的认知模型,我们以程序员群体在公司工作为例: 在感觉层面:略在知觉层面:略在经验层面:要梳理出来自己目前真正能为己所用的经验有哪些,这些经验背后的理论你可能不太清晰,也没有深入地想过,但是你按照之前的做法去做,对应的问题就能解决。在知识方面:梳理出来自己掌握哪方面的知识,比如地理、人文社科等知识可以整理在这个层面。当然大多数研发同学掌握着信息技术的基础知识,例如计算机原理、网络原理、存储原理等,以及自己技术栈相关的知识,例如大数据技术、Java开发技术等等在方法论方面:大家或多或少听说过一些方法论,比如 5W2H、SWOT、SOLID、AARRR等等,把你自己实际工作中经常用到的方法论整理出来在理论方面:把自己能够复述出大概的理论可以列出来,这些理论可以是任意领域的,揭示某个现象或者结论背后的原理的,都可以归为理论。例如信息技术中分布式技术体系的基础理论CAP理论、物理学、经济学中各种理论等等。在科学层面:大多数信息技术的从业者在工作中用到的知识多数来源于计算机科学。一些双学位的读者可能还掌握其他科学领域的知识,也可以列出来。在哲学层面:可以结合个人实际情况来列出自己掌握的常见的哲学概念和理论,例如马克思主义哲学、毛泽东思想等等。2. 明确自己的认知层次结合上面的模型,再分析自己日常工作内容,可以将工作内容主线梳理出来,判断在这些工作内容中,哪个层面的内容发挥着最大的作用,其他方面只起到辅助作用。还是以开发人员为例,大多数人的工作内容就是把外界输入的需求转换为计算机可识别的代码,然后确保代码运行逻辑正确结果无误服务正常。很多人会疑惑,这其中既有知识发挥作用,也有经验发挥作用,究竟哪个是主导?其实这个问题既和个人实际情况有关,又和这个事情的专业性有关。大多数人在接触一个事情的初期,知识占主导作用,经验起辅助作用,而后续事情熟悉以后做的多了经验开始占主导作用,知识起到辅助作用;一般情况下,一个事情越专业,知识起到的作用越大,专业性越小,经验起到的作用越大。当然也有的程序员在工作中依靠的是更高层次的方法论来指导日常研发工作,例如面向对象程序设计的6大原则、例如DDD领域驱动设计&开发等,这两种不同的程序员群体在做开发这件事情上认知层次不同。读者可以结合自己日常实际情况来确定自己的认知层次。 3. 判断个人认知层次与日常做事的匹配度在确定个人在某个领域的认知层次之后,再分析自己当前的认知水平是否能够支撑自己每天做的事情。一般情况下有三种情况: 认知层次不足以支撑要做的事情,比如外行炒股、民间科学家,比如新晋升的管理者等。这种情况就需要进行认知提升了,需要围绕要做的事情的命题来学习知识(看正规的教材)、了解前人的经验(看业内大佬的书)、在实践中运用知识从而加深对知识的理解等等。 认知和做的事情匹配。这种情况比较普遍,大多数人都处于这个状态。但是这个状态持续时间太长,也意味着要做出一些改变了,要么让自己做的更轻松,要么让自己做得更有效率,总之新的期望提出之后,往往伴随着潜在的认知提升的要求。 认识层次很高,但是做的事情很基础,基本上靠经验就能搞定。这种情况下就要结合自己的内心真实的想法和实际客观限制来判断要不要尽快做出改变了:比如主动迎接更大的挑战,如果客观环境有限制,则要考虑是否更换平台寻找更大的成长空间。 在明确了自己认知层次与日常做事的匹配度以后,就知道当前阶段是否处于“亟需提升认知的阶段”,如果处于这个阶段,那就建议定制认知提升的策略和计划了;如果判断自己无需提升认知,那就说明进入了“实践完善认知”的阶段,就需要针对关键事情做好复盘,让认知更深入、更全面。 4.1.3 定制合理的认知提升策略和计划在确定自己需要提升认知的情况下,就要明确策略、定制计划了。在定制策略方面,首先要确保基础认知全面且深入,即优先保证个人认知在本职工作方面的专业性、深入性、全面性。这一点对于初入职场的人来讲非常重要,涉及到是否能够完成个人在组织内的基本价值的创造。其次要有意地去高效地掌握更高层次的认知。面对某个事物,沿着认知分层模型自底向上逐步体验、感悟、总结、思考,是一般的、非高效的“认知建立的过程”。而在哲学理论指引下自顶向下地去学习知识,同时自底向上结合实际情况验证知识、加深掌握,才是更高效的掌握更高层次认知的方法。在定制计划方面,无非就是阅读、实践同时进行,后面会在实际案例中介绍作者在团队管理过程中使用的办法,这里就不再赘述了。4.2 提升实践水平提升实践水平,是人成长过程的载体。4.2.1 提升实践效率“提升实践效率”在微观和宏观层面有两层含义。第一层是指在微观视角下,在做具体某个事情时,我们需要做到现在比过去做得更高效,即单位时间内产出更高。效率症结的发现、背后问题的定义、借助制度或工具打破效率卡点,就是深入问题解决问题并且成长的过程。很多职场人已经习惯了按部就班地完成日常工作,而不去思考自己的工作整体业务流程是怎样的,自己负责的环节是否可以更高效,自己在工作中的产出是否能有更高的质量,其实就是白白浪费了成长的机会——按部就班地做事只是完成了组织角色的基本价值要求,而提升实践过程的效率,就是个人发挥主观能动性,在认知提升的指引下创造出因为个人成长而带来的价值增量的过程。这是提高实践水平的主要方式。我们以信息行业的开发者为例,例如同样是做前端页面,有的人做了几年,每次来需求都是重复去做,把脑力劳动做成了体力劳动;而有的人则通过组件库解决前端控件的产品内复用问题、通过各种搭建工具来解决前端组件的跨产品线复用问题——前者的成长几乎没有,而后者的积累造就了无代码/低代码的技术基础。对于服务端开发而言同样如此,从过去的人肉测试、人肉运维、人肉应急发展到现在的各种质量平台、Devops平台,就是一个群体不断地提升实践效率的结果——这个过程既开启了信息技术行业内的新的细分领域,并形成了很多行业标准,也涌现出了很多行业领军人物,这些领军人物也是通过这个过程成长起来的,不是凭空出现的。提升实践效率的第二层含义是指,在宏观视角下,缩短“人成长的过程”所耗费的时间,即单位时间内成长更大。一般情况下,完成一项工作需要的能力是多维度的。这些维度中,基本职业技能属于基础维度,其他属于支撑维度,随着人的成长,自己扮演的角色会逐步变化,不同维度在不同阶段的重要性不同,也意味着那些当前阶段并非重点的维度,可能在下一个阶段却恰恰是需要重点成长的。这也就揭示了一般人的成长是多维度的、不同维度之间是串行的规律。例如大多数人都是职业技能成长达到专家级别,开始带团队后,才开始关注管理维度的能力的成长。而对于单一维度的成长,大多数人都要经历 “实践”——“复盘总结”——“再次实践改进”——“更好的产出”这样的大过程。对于大多数的普通人而言,这个过程中存在着以下几个制约人成长的瓶颈:一方面第一个实践环节耗费较长时间,其次复盘总结部分存在认知层次壁垒需要突破,是最耗费时间的部分,就导致一个人想要通过 “实践”——“认知”——“实践” 这个循环来成长,至少要经历2-3轮(这个人悟性相当高的情况下)这样的过程,这就导致在这方面成长需要很多时间。例如大家经常参与的比较大的项目,往往是经过大半年的苦战最后做复盘,经历多次,才会在某方面有成长,而这过程基本上一年到一年半就过去了。基于以上的特征可知,想提升成长效率,要做的就是尽量在一个实践过程中,同时提升自己不同维度的能力,把过去多维度的串行的成长过程改变为并行的成长过程。同时,针对成长中最花时间的部分,通过提前体系化地学习相关领域的知识来指导实践,再用实践完善认知,从而加速个体在单个维度上的成长。所以想要比同龄人有更快的成长速度,就要盘点清楚自身的角色、工作的基本技能和下一个层次的能力要求,在本职工作的实践过程中,同时有意识地去学习其他维度的知识并运用于实践中,让基础能力提升的同时,其他方面的能力也一起成长,这样就能让自己的综合能力更强,更具竞争力。我们会在后面的章节中给出一个人的综合能力有哪些维度,分别对应日常工作中的哪些方面,方便大家结合自己的实际情况来定制自己的成长计划。4.2.2 降低实践成本降低实践成本有两层含义。一是在微观视角下,降低具体做某件事情的成本。降低成本往往意味着改良过去的做事方式,提升技术水平,通过更高的标准来让人不断进步。二是在宏观视角下,降低整体实践过程的成本。利用可以利用的各种客观条件,降低个人成长过程中的试错成本,从而降低成长本身的时间成本。降低实践成本与提升实践效率一样,都是针对大家习以为常的现状提出改进的大方向,设定更高的目标,从而让过去做事的过程变成驱动个体成长的过程,这里不再针对降低实践成本进行赘述。4.3 提升价值及其评价提升价值既是从客观上创造更多的价值,也是扩大价值影响力,从而让主观评价更高、个人更被群体认可。“价值的提升”是“评价更高”的本质,“评价更高”是“价值提升”的现象。我们既要重视价值本身的提升,也要重视评价体系对价值的正向或负向的影响。在人的成长过程中,本质与现象都需要关注并且投入精力去做改善。4.3.1 围绕“价值的创造与交换”的全生命周期提升价值结合本文提到的价值创造和交换过程来看,价值的创造过程在客观上决定了价值本身的大小,而价值的交换过程决定了价值的评价结果。所以个人想要让某个群体觉得他成长了,那么在一方面要在做事上下功夫,做出更好的结果,另外一方面则要确保做出的结果被目标群体认可。以研发人员为例,既要在技术领域能够攻克技术难关、完成业务需求开发,也需要让自己做的事情被主管、合作伙伴、客户认可,获得他们的积极评价。所以在研发过程中一方面要通过技术创新降低业务成本,日常工作中提升研发效率,确保产出的质量,这就是在客观上做出更高的结果;另一方面要让自己做的事情被协作方认可,就要分析自己对协作方的价值是什么,自己做的事情解决了他们哪些问题,在做出结果之后(创造价值),要和协作方做好同步沟通(营销),让他们后续有更重要的合作时来找你(销售),在完成需求上线后持续跟进,不断改进提升从而做出对业务的更积极的影响(持续交付),最终协作方一定会在关键项目中优先考虑找你合作(价值持续流转)。价值流转的过程环节众多,其中一环出问题,极有可能影响目标群体对客观价值的判断,从而让价值的主观判断低于实际价值,所以我们要避免这种情况出现。有的人常常说,我技术能力特别好,但是协作方每次合作都抱怨不愿意跟我配合——那就要复盘你对协作方的价值流转过程中哪个环节出了问题,是你做了很多对他们有价值的事情但是没有表述清楚,导致他们不知道(这属于“营销”出了问题),还是你每次承诺要做好的事情结果都办砸了所以他们惧怕与你合作(这是价值交付出了问题),总之经过复盘并且改进以后,才能让对方觉得你“成长”了。还有的人说我辛辛苦苦做一年,事情做的非常棒,但是晋升过程就是不如那些会说的——这个场景也同样需要沿着价值的创造过程和价值流转过程进行一个环节一个环节地复盘和改进,从而才能让组织觉得你有成长。读者如果现在已经感觉到了自己进入了某种瓶颈,那么就沿着价值创造与交换的所有环节去做盘点,分析自己是不是在那个环节有问题,着重优先解决瓶颈环节就能带来自己以及目标群体认可的成长。4.3.2 识别创建价值的模式,做模式升级从创造价值的模式来看,想要让自己成长,还可以通过升级自己创造价值的模式来完成。过去只负责一件事情的一个环节,是否能通过能力的提升来负责整件事情;过去只能负责一件事情,是否能够通过能力的提升将这个事情的上下游事情也负责起来;过去只能单枪匹马单兵作战,是否能够通过能力的提升来带领别人一起做事;过去带领团队做事,是否能够通过能力的提升来完成跨团队的协同作战。创造价值的模式的发生变化,有所提升时,创造的价值也往往更大,更容易被周围的人感知到你成长。所以读者可以仔细分析一下自己当前创造价值的模式是什么,是否可以依靠过往的积累来转变创造价值的模式,从低层次提升到高层次,从低效率提升为高效率。五、实践案例:如何培养团队成员,构建团队成员的成长路线图基于以上理论分析,本文作者进行了较长时间(团队正式员工大约2-3年,外包员工大概1年-1年半)的实践,在实践过程中不断调整方式方法,最终总结了行之有效的实际操作方法,本章节就把这些方法分享出来。具体案例背景如下:研发团队既有原厂员工,也有外包员工团队成员中既有刚毕业的实习生,也有工作三年以内的初级工程师,也有工作十年以上的技术专家处于不同职业生涯阶段的人核心利益诉求不同处于不同职业生涯阶段的人工作内容和模式不同处于不同职业生涯阶段的人需要成长的维度也不同所以作者在团队管理方面面临的挑战有以下几点:如何让水平差异巨大的团队成员产出一致的、高标准的业务结果?如何通过团队管理工作激发出不同类型、不同职业生涯阶段的员工主动性和创造力?如何让成长路线差异巨大的不同的团队成员在业务上具有相同的向心力,让业务发展惠及每个人,也让每个人都为业务发展做出自己的贡献?上面这些问题是综合性的问题,想要解决这些问题,需要在业务、技术、团队、个人特长和兴趣几方面多维度着手进行调整。虽然上面的背景中介绍了团队成员呈现出一定的差异性,这些差异存在于多个方面,看似无法解决,但是事实上通过沟通和观察发现,大多数的团队成员作为技术人员,都有一个共性,就是对于个人成长的渴望都是相同的。同时对于团队而言,员工个人能力的提升也对生产效率和质量有积极影响,所以在“组织管理——团队成员培养”层面做出恰当的实践就是解决上述问题的必然途径了。接下来就着重讲解作者在实践层面做了哪些事情来满足团队成员的成长诉求,指引并帮助大家成长。5.1 明确个人职业发展路线图5.1.1 一对一沟通结合问卷调查,明确团队成员的职业发展目标具体问卷包含以下几个方面的问题: 职业化素养 目的:了解员工目前基础职业素养现状作用:用于评估员工是否需要在职业化方面加强培养基础专业能力 目的:了解员工目前基础专业能力现状作用:用于评估员工是否需要在基础专业能力方面加强培养,明确后续培养计划的重点员工优劣势评估 目的:了解员工自身能力领域分布情况,擅长哪类事情,不擅长哪类事情作用:用于了解员工能力分布领域的现状,评估员工适合做哪方面的工作,避免分配员工不擅长的领域而造成资源与业务错配,最终服务于激发员工自身对业务的兴趣、认可和价值感。员工潜力评估 目的:了解员工成长意愿、员工未来成长空间、员工未来突破现有瓶颈的可能性作用:用于判断员工是否值得培养,确定培养该员工需要投入多少精力员工认知能力评估 目的:了解员工对工作中常遇到的事物的看法和决策偏好作用:用于评估员工当前阶段在认知层面是否需要提升培养以下给出作者本人在团队内部实践过程中,形成的几份相互配合的调查问卷,如下图所示(对具体问卷内容感兴趣的可以联系作者索取):图7 员工基本情况调查问卷5.1.2 结合个人调研情况和喜好及优劣势,明确个人职业发展方向选择特意强调:以下几个方向讨论的范围是研发团队,选择方向的主体是研发人员,而非综合业务团队,因此第二个“做业务”、第三个“做综合管理”都脱离不开专业技术能力的支撑,也就是都是以“做技术”为前提的。 纯做技术 特点:面对的问题维度聚焦于技术领域,问题多数来源于业务发展过程,对技术深度有较高要求。要求:掌握对应的专业知识,能够静心深入地研究技术疑难问题,需要较高的专注力做业务 特点:面对的问题维度多,对人员综合能力要求高,与人协作占比大,对业务理解能力有较高要求。要求:在对技术能力有要求的同时更强调综合能力,在专业技术做好的基础上,愿意与人在非技术领域相关工作中开展深入协作,同时掌握看清事物本质的能力,掌握将业务抽象为技术体系语言、模型的方法论做综合管理 特点:在技术、业务维度之上,新增了团队管理维度,并且为团队发展方向(即做什么业务,做到什么程度,怎么做)负责要求:具备技术能力、业务能力的基础上,能够履行组织赋予管理者角色的工作职责,协作模式更复杂,对上对下对平级对上下游都需要有能力来较好地完成对应场景的协作工作,释放团队成员的成长潜力,提升员工的工作产出价值。5.1.3 结合员工基础调研问卷和职业发展方向来创建个人职业发展路线图 职业发展路线图是一个员工在职场内的长期发展指引。职业发展路线图定义了员工期望自己在什么时间达到什么样的职业成就,相当于个人的发展愿景的拆解。大多数研发人员在毕业以后都会想象自己未来会成为一个技术大牛,但是这种想象不是具体的、可以引导个人能力发展的大图。我们需要设定职业生涯目标之后按照阶段进行拆解,并且要认识到成长的艰难,合理预估每个阶段可能会耗费的时间,同时需要继续拆解每个阶段需要掌握什么样的知识、进行什么样的实践、做出什么样的复盘和总结。其次要认识到事物是发展的,要以发展的眼光看待个人职业发展路线图,随着自身认知和能力的提升,及时调整职业发展路线图,避免原本应该起到指引作用的它在未来限制个人成长。我们以一个信息专业的普通毕业生为例,给出他可能的职业发展路线图(注意,这是一般规律下的职业发展路线图,不讨论特殊情况,比如有人会转行,有人去做测试,有人转做产品经理等等):图8 一般软件研发人员职业发展路线图5.2 构建个人能力模型大图明确个人职业发展路线图只是走出了万里长征的第一步,明确了自己的期望,但是接下来每个阶段应该如何成长,仍然是未知的,需要理清楚的。所以需要读者自己或者团队管理者来帮助团队成员完成他自己的个人能力模型大图,用来彻底了解自己目前的真实水平。这里我们不再讲太多理论性的东西,直接给出一个信息技术专业从事业务开发的研发人员能力模型大图,具体如下:图9 个人能力模型大图为什么一个软件研发人员的能力模型会包含这些维度?答案也很简单(对于看完本文全部内容的读者来说是简单的),就是:a. 要围绕人的价值模型构建人的能力模型。就是如果你的价值是做软件研发,那就围绕“通过软件研发来创造价值”这个过程来构建自己的能力模型,而不是围绕 “画画”或“唱歌”来构建自己的能力模型。b. 围绕人的成长模型构建人的能力模型。就是要结合个人职业生涯的发展情况,来针对性地构建能力模型,更直白一些,就是围绕“更高层次的价值创造模式” 来构建自己的能力模型,而不是把“10岁的时候能一口气跳30下跳绳” 作为自己能力模型的核心。c. 要以价值的维度来分维度构建个人能力模型,即价值包含哪些维度,则个人能力模型需要有与之相匹配的维度。d. 要认识到个人不同发展阶段的能力侧重点不同,即个人能力模型是分阶段的,而不是一成不变的。e. 认识到不同的能力属于不同的层次。对于大多数人而言,在个人能力模型中,要分成两个大类,一类是需要自己阅读来提升的,比如各种专业基础技术知识、各种技术原理和理论;另一类是可以结合业务需求进行实践进行提升的,比如分布式系统中常见的各种中间件的应用、基于业务场景的技术架构设计、业务开发过程中的业务建模方法论等。很多人会走入“成长的误区”,把太多精力放在了“学习知识”上,不管自己是不是做数据库研发工作的,都要死记硬背各种数据库底层技术原理,而忘记把精力分出来提升和自己主职工作有关的技术实践上,最后看起来每天都在“学习”,可是过几天过几个月都忘得一干二净了。所以要避免进入这种误区,一方面要学习各种知识和理论,同时另一方面要紧紧围绕自己的个人能力模型来进行实践。在经过评估得出自己的情况以后,就能知道自己哪方面尚有欠缺,哪方面是优势已经达标甚至超过了当前层次的水平,就能比较客观地得出自己的能力模型。下面给出我当前团队目前在使用的个人能力模型评估表,供大家参考,内容较多,只放出一部分,如下图所示:图10 个人能力模型评估表5.3 结合职业发展路线图与个人能力模型构建个人成长路线图在得出自己的能力模型之后,再结合个人职业发展路线图,就知道当前阶段哪些维度的能力需要加强,下一个阶段哪个维度的能力是重点,需要提前学习相关的理论知识来提升认知,最终就能把职业发展路线图变成可被执行的、细化后的个人成长路线图。个人成长路线图一般以一年为1期,每3个月为一个小的阶段,来体系化地推进自己的多维度的成长目标,以本人团队中一个同学半年尺度的成长路线图为例,具体如下图所示:图11 成长路线图注意,此时的成长路线图仍然是规划层面,还没到执行层面,同时读者可以对比该同学的个人成长路线图和本文上面提到的个人能力模型的差异,即:成长路线图是结合个人职业发展规划和个人现阶段能力评估的结果,找出自己在未来半年内想要重点成长的维度,以脑图的形式呈现出来,对接下来的工作做指引。5.4 依托业务需求+技术命题设定个人成长里程碑在完成了职业发展路线图、个人能力大图、个人成长路线图以后,相当于走出了第二步,完成了计划和个人情况的摸底,剩下的最后一步,也是最关键的一步,就是结合业务发展,把这些“虚”的规划变成真正的执行,让个人成长和业务发展相辅相成。具体操作办法如下:由正式员工带领外包员工进行业务需求分析,除了了解业务需求和逻辑之外,还要提出需求背后的技术命题是什么。这个技术命题可能是未来业务发展壮大业务规模急速膨胀所带来的系统性能的挑战,也可能是核心业务对资金准确性、系统响应时效的高要求带来的挑战,也可能是大批量数据处理的成本压缩带来的技术方案的创新带来的挑战,也可能是跨BU项目协同方面带来的项目管理方面的挑战,总之这些技术命题直指业务背后所暗含的本质和关键挑战。 结合技术命题的情况选择合适的人选来完成对应业务需求的开发工作。在做技术命题和人员匹配时,结合团队人员的成长路线图、成员负责的业务域、成员现阶段实际情况等综合考虑。将业务需求纳入项目管理中,将技术命题纳入个人成长里程碑中,研发侧对业务需求的开发实行双轨制跟进。 这一步是把业务发展和个人成长结合的保障性措施,即有助于研发人员成长的、决定了业务需求关键挑战的这些技术命题,不再糅合到业务需求的项目管理中,而是单独专线跟进,与业务需求排期对齐,确保在组织协作机制和流程上来把事情落实到位。又由于技术命题的重要性,这么做同时也是在组织制度和研发流程上,确保了“一次性把事情做对”的组织文化可以被实际落地,避免了一线研发人员由于所谓的“赶排期、压力大”而把业务需求背后的关键技术问题忽视掉导致未来引起更大的问题。在技术方案设计及评审阶段,需求承接人需要对技术命题做出分析和解答,以此为基础讲清楚当前需求的技术方案细节,即:把技术命题的分析、详细设计、评审融入研发流程中,由TL、有经验的架构师、一线编码员工共同完成这个环节,在完成业务需求的同时,让一线研发人员把技术问题吃透。 业务迭代周期进入尾声后,总结复盘在本迭代中实践过程中的得失,除了业务方面需要复盘以外,技术侧还需要结合个人实际情况完成技术命题落地情况的复盘以及个人对技术命题实践总结,由此环节进行收尾,完成个人成长里程碑。 随着业务的发展,不断重复1-5的过程,让业务开发人员在完成业务需求的同时,不断积累并完成自己个人成长里程碑,从而到年度回顾的时候,再次评估个人能力模型是否在需要成长的维度上有了真正的成长。目前团队在进行完整的实践并且持续了几轮迭代,对于正式员工而言,提炼业务需求背后的技术命题也锻炼了大家直指问题本质的能力,开阔了技术视野,让他们在埋头苦干的同时做到了在技术领域抬头看路;对于外包员工而言,在进行业务需求开发的同时深入研究并实际实践技术命题,切实提升了他们的业务开发能力和技术能力,对于提升团队产出质量、提升团队成员归属感、增加工作积极性等方面都有非常明显的正向变化。下面给出团队中某个同学的“个人成长里程碑”(属于个人成长路线图的实际载体)以及其中某次业务需求迭代中包含的“技术命题”,如下图所示(涉及隐私及数据安全,已打码):图12 个人成长里程碑及技术命题以上就是结合“人成长的本质”相关的理论,所进行的完整的实践案例,希望对一线研发同学的自我成长以及管理者团队成员培养工作有一定的启发作用。六、 总结首先,作者把文章中讲的理论概念,使用一张图来做抽象概括,方便大家看到整体文章的核心概念之间的关系,具体如下:图13 文章核心概念抽象总结其次,我们在本文背景部分,提出了几类人在不同职业生涯阶段所面临的因成长而起的困扰,其实本文正文中其实已经给出了答案,这里再简单总结一下:在《1.1 新人的选择困局》中所提到的问题,其实就是因为出入职场的新人没有好的方法、没有人协助来建立起职业发展路线图引起的,所以解法就是多向行业前辈收集必要的信息,然后按照本文实践案例中介绍的方式构建个人职业发展路线图即可。 在《1.2 高人才密度环境下普通员工的成长效率困局》中所提到的核心问题,其实就是因为大家在职场中的成长过程太“按部就班”了,一定要“吃一堑长一智”才能成长。其实普通一线业务开发人员更需要“并行地”提升个人能力,缩短 “认知——实践——总结——认知提升——实践提升”这个过程,带着理论、方法论去实践,把最耗时的部分时间压缩。并且及时为“升级创造价值的模式”而做铺垫,同时结合第五章的案例中介绍的实操办法来把个人成长和业务开发过程结合起来,这样就能提高成长效率,和“天赋党”、“经验党”、“嫡系党”一较高下。 在《1.3 业务发展和个人成长的二元对立困局》中所说的问题,其实就是因为没有合适的方法把业务和个人成长结合起来的缘故,所以读者可以参考案例中介绍的方式,自己来分析业务需求背后的技术命题,并用技术命题的解决来让业务需求开发的过程含金量更高,问题解决的也更彻底。 在《1.4 中年打工人低费效比引发的职场生涯终结困局》中所说的问题,其实就是需要大家在不同的职业生涯阶段用不同的方式创造价值,也就是需要升级自己创造价值的模式,而价值创造的模式的升级并不是一蹴而就的,所以需要大家提前布局,并且有意识地结合那些“更高层次的价值创造模式”对人的能力要求,来提升个人能力。 在《1.5 管理者的PUA困局》中提到的问题,其实就是管理者需要在成员培养方面结合理论知识来进行有效的、高效的引导和培养,可以直接参考第五章中的实践案例,结合读者自己团队的实际情况,真心诚意地深度参与到团队成员个人成长的过程中,那么员工肯定会感知到你的良苦用心,知道你不是在画饼,知道你是在为他的成长负责,所以就不会再认为你的管理动作是PUA了。 再次,需要着重强调:道路是曲折的,前途是光明的。我们自己要充分认识到成长的艰难和曲折,同时更要认识到掌握合理科学的成长方法论并且实践之后,那么个人的成长是必然的。希望每个渴望成长的人不要遇到困难就轻言放弃。最后,一定要重视认知提升,同时不忘将其和实践相结合。本文作者该系列文章写了很多篇,有些文章比较长,并且偏理论,很多人会觉得过于偏重理论就不愿意细看,觉得“你说的道理我都懂”。可是如果不把来龙去脉讲清楚、不把实践依赖的理论依据说明白,直接分享所谓的最佳实践的“干货”案例,读者读完文章以后只能做到“我知道有人这样做”,却无法做到“我知道他为什么这么做”,更无法判断“他这么做是不是对的,是不是好的,是不是有效的,是不是高效的”,也就更难做到把所谓的“最佳实践案例”恰如其分地应用在自己面对的问题中,自然也就无从谈起“它山之石可以攻玉”了。那么,这样的所谓的“干货”除了让大家照着模仿以外,真的能对大家产生更深远的帮助吗?我们常常说“知其然,更要知其所以然”,可是很多人期望看到事物的本质,却不愿意阅读相关的理论,更不愿意动手试试,错把“知道”当做“懂了”,错把“听过、见过”当做“做到了”,这其实就是大多数人成长路上的最大的绊脚石。认知不提升,实践效率就会低,实践效率低,成长就比他人慢,因果循环自食其果——期望有缘人多看、多思考、多分析、多讨论、多实践,从而找到适合自己的成长之路;至于无缘之人,大家彼此放过,相忘于江湖罢。
前端实践:如何防止xss跨站脚本攻击(vue代码说明)
XSS(跨站脚本)攻击是一种常见的网络安全漏洞,攻击者通过在网页中注入恶意脚本代码,从而实现窃取用户信息、盗取会话令牌等攻击目的。为了防止XSS攻击,我们可以采取以下措施:输入过滤和验证:在接收用户输入时,进行输入过滤和验证,去除或转义用户输入中的特殊字符和HTML标签,从而防止攻击者注入恶意代码。 输出转义:在将数据输出到页面时,对特殊字符和HTML标签进行转义,从而防止攻击者通过注入恶意代码来窃取用户信息或攻击网站。 CSP(内容安全策略):在网站中添加CSP策略,限制网页中可以加载的内容和脚本,防止攻击者通过注入恶意脚本来攻击网站。 HTTP Only Cookie:将Cookie标记为HTTP Only,防止JavaScript脚本通过document.cookie获取到会话令牌,从而防止会话劫持攻击。 输入长度限制:限制用户输入的长度,防止攻击者利用长 综上所述,为了防止XSS攻击,我们需要在输入过滤和验证、输出转义、CSP、Cookie管理、程序设计原则等多个方面进行综合考虑和应用。以下是一些代码示例和配置选项,演示如何使用上述措施来防止XSS攻击。1、输入过滤和验证在Vue.js中可以使用v-model和自定义指令来实现输入过滤和验证。例如,可以使用以下指令过滤用户输入中的HTML标签和特殊字符:Vue.directive('filter', {
bind: function(el, binding, vnode) {
el.addEventListener('input', function() {
let value = el.value;
value = value.replace(/<[^>]*>|[\r\n\t]/gi, '');
value = value.replace(/[&<>"]/gi, function(match) {
switch (match) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '\"':
return '&quot;';
}
});
vnode.context[binding.expression] = value;
});
}
});然后在模板中使用v-model和v-filter指令来绑定数据和进行输入过滤和验证:<template>
<div>
<input v-model="inputValue" v-filter>
</div>
</template>2、输出转义在Vue.js中可以使用v-html指令来将数据渲染为HTML代码,并自动转义其中的特殊字符和HTML标签。例如,可以使用以下代码来渲染一个带有特殊字符和HTML标签的字符串:<template>
<div v-html="htmlString"></div>
</template>export default {
data() {
return {
htmlString: '<div>Hello & World!</div>'
};
}
}在渲染时,Vue.js会自动将字符串中的特殊字符和HTML标签转义为<、>、&等实体字符,从而防止攻击者注入恶意代码。3、CSP在Vue.js中可以使用vue-meta库来添加CSP策略,限制网页中可以加载的内容和脚本。例如,可以使用以下代码来添加CSP策略:import Vue from 'vue';
import VueMeta from 'vue-meta';
Vue.use(VueMeta, {
keyName: 'metaInfo',
attribute: 'data-vue-meta',
ssrAttribute: 'data-vue-meta-server-rendered',
tagIDKeyName: 'vmid',
refreshOnceOnNavigation: true,
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"]
}
});在contentSecurityPolicy选项中,可以设置不同的源策略(如defaultSrc、scriptSrc、styleSrc等)来限制不同类型的资源的加载。例如,上述代码设置了只允许加载本地资源和内联的JavaScript脚本,同时禁止加载其他域名的脚本和资源。4、HTTP-only Cookie在Vue.js中可以通过在服务器端设置HTTP-only标记来防止XSS攻击。当HTTP-only标记被设置时,浏览器只会在HTTP请求中发送cookie信息,而禁止使用JavaScript等脚本来读取或修改cookie。例如,在使用Express.js作为服务器端框架时,可以使用以下代码来设置HTTP-only标记:const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.get('/', function(req, res) {
res.cookie('sessionId', '12345', { httpOnly: true });
res.send('Hello World!');
});在上述代码中,使用cookie-parser中间件来解析HTTP请求中的cookie信息,并通过res.cookie方法来设置HTTP-only标记。这样,即使攻击者注入恶意JavaScript代码,也无法读取或修改cookie信息,从而保护用户的隐私和安全。5、Content Security Policy (CSP)在Vue.js中可以使用Content Security Policy (CSP)来限制网页中可以加载的内容和脚本,从而减少XSS攻击的风险。CSP可以通过HTTP头部来设置,例如:Content-Security-Policy: default-src 'self' *.trusted.com; script-src 'self' 'unsafe-inline' *.trusted.com上述代码中,default-src指定默认的资源加载策略,script-src指定允许加载JavaScript脚本的策略。'self'表示只允许加载当前域名下的资源,*.trusted.com表示允许加载指定的域名下的资源。'unsafe-inline'表示允许使用内联脚本。在Vue.js中,可以使用vue-meta库来添加CSP策略,例如:import Vue from 'vue';
import VueMeta from 'vue-meta';
Vue.use(VueMeta, {
keyName: 'metaInfo',
attribute: 'data-vue-meta',
ssrAttribute: 'data-vue-meta-server-rendered',
tagIDKeyName: 'vmid',
refreshOnceOnNavigation: true,
contentSecurityPolicy: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"]
}
});在上述代码中,使用contentSecurityPolicy选项来设置CSP策略,可以通过不同的源策略来限制不同类型的资源的加载。例如,defaultSrc策略指定默认的资源加载策略,scriptSrc策略指定允许加载JavaScript脚本的策略。"'self'"表示只允许加载当前域名下的资源,"'none'"表示禁止加载该类型的资源。需要注意的是,CSP策略不是万能的,攻击者可能会使用一些方法来绕过CSP的限制。因此,还需要使用其他的防御措施来提高网站的安全性。6、Sanitize HTML在Vue.js中可以使用DOMPurify库来过滤和清理用户输入的HTML代码,从而减少XSS攻击的风险。DOMPurify库可以检测和清理HTML代码中的恶意代码,包括JavaScript脚本、HTML注入、CSS注入、URL跳转等攻击方式。例如,在Vue.js中可以使用以下代码来过滤用户输入的HTML代码:import DOMPurify from 'dompurify';
const dirtyHtml = '<script>alert("XSS Attack!");</script><a href="http://example.com">Link</a>';
const cleanHtml = DOMPurify.sanitize(dirtyHtml);
console.log(cleanHtml); // <a href="http://example.com">Link</a>在上述代码中,使用DOMPurify库来过滤用户输入的HTML代码,使用sanitize方法来返回过滤后的代码。在过滤HTML代码时,DOMPurify库会删除所有的JavaScript脚本、HTML注入、CSS注入、URL跳转等攻击代码,从而保护网站的安全性。7、Escape Output在Vue.js中可以使用{{}}语法来显示动态内容,需要注意的是,需要对显示的内容进行转义,从而避免XSS攻击。例如,在Vue.js中可以使用以下代码来转义输出的内容:<template>
<div>
{{ escape(content) }}
</div>
</template>
<script>
export default {
data() {
return {
content: '<script>alert("XSS Attack!");</script>'
}
},
methods: {
escape(content) {
return content.replace(/[<>"&]/g, function(match) {
return {
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'&': '&amp;'
}[match];
});
}
}
}
</script>在上述代码中,使用escape方法来转义输出的内容,使用正则表达式来匹配需要转义的字符,并使用替换函数来替换字符,从而实现转义输出的功能。需要注意的是,转义输出并不是万能的,有些字符可能会被转义后失去原来的含义,因此还需要使用其他的防御措施来提高网站的安全性。
数据湖存储的安全写入之道
作者:焱冰@阿里云焱冰背景数据湖的兴起,给数据存储带来了一轮新的革命。越来越多的公司选择将存储切换到云上对象存储。因为云上对象存储往往意味着大容量、低成本、易扩容。说到对象存储,必然涉及到 S3 协议,S3 协议已经事实上成为对象存储的通用协议。不过,市面上不少数据平台公司,也会选择基于 S3 协议又兼顾 Hadoop 使用习惯的 S3A Connector,比如 Databricks 在对象存储上提供的表数据结构 Delta Lake。我们就以 Hadoop 社区中的 S3A Connector 的实现为切入,来分析一下数据湖写入路径的安全性。Hadoop S3 的写入支持因为 S3 协议本身不支持增量写入,因此 S3A 实现时默认的写入方式是先通过缓存到本地,最后在文件 close 后再上传到对象存储。但是,这种默认的方式并不一定高效,对大文件来说,在 close 被调用前,本地已经缓存大量的数据,这样会造成 close 操作非常耗时,文件写入整体看也不高效。从 Hadoop 2.8.5 版本开始,可以通过设置 fs.s3a.fast.upload 为 true,打开快速上传来优化写入路径。打开后,可以边写本地缓存块,边将满足大小的块异步上传(默认 100M 一个块)。这样也满足了对象存储中分阶段上传接口的一些限制,比如单个块不能小于 5M,分块总数不能大于 10000。通过阅读 Hadoop 2.8.5 相关源码,我们可以发现打开 fs.s3a.fast.upload 后,S3AFileSystem 在创建文件时会打开 S3ABlockOutputStream(Hadoop 3.x 也有类似的 S3AFastOutputStream)随后,S3ABlockOutputStream 在处理 write、flush 等操作时,则会调用一个抽象的 S3ADataBlock 来执行。而 S3ADataBlock 则可由三种工厂方法来创建,分别创建基于堆内存的 ArrayBlock、基于磁盘的 DiskBlock,或者基于堆外内存的 ByteBufferBlock。选择哪种工厂,由 fs.s3a.fast.upload.buffer 这个配置项控制,默认为磁盘(disk)。其他两种可选配置为堆内存(array)和 堆外内存(bytebuffer)。磁盘的问题通过了解 Hadoop 社区中 S3A 的实现,我们发现借助磁盘缓存数据是常见甚至默认的行为。因为这样可以减少内存占用,缓存更多的数据。但是,这样也带来了磁盘本身的阿喀琉斯之踵 -- 磁盘的稳定性问题。在数据存储领域,磁盘的问题往往非常令人头疼。比如磁盘写满,磁盘坏道问题,还有偶现的磁盘数据比特反转导致的数据安全性问题。哪怕单块磁盘的可靠性非常高,但由于磁盘出现问题的概率会随着磁盘数的提升而变大,这会使数据安全性蒙上一层阴影。对于R个副本的情况,设磁盘的年故障率为P,磁盘数为N,则整个机群有C ( N, R ) = N! / ( R! * ( N- R )! ) 种 R 副本的组合方式。机群数据总量为 M,分片大小为 T,那么有 R 个磁盘同时损坏造成数据丢失的概率是:* 引用于《磁盘故障与存储系统的年失效率估算》因此,要保证写路径的数据安全型,我们不能完全依赖底层存储介质的保证。仍需要我们在数据写入时就做一些努力。我们先来做一些实验来看看 S3AFileSystem 在这些问题上的表现。模拟磁盘 IO 问题修改 core-sites.xml 中的 fs.s3a.buffer.dir 指向 /dev/vdc 所在的路径,比如我机器上的 /data2/ <property>
<name>fs.s3a.fast.upload</name>
<value>true</value>
</property>
<property>
<!-- 本地 buffer 缓存目录,不存在会创建 -->
<name>fs.s3a.buffer.dir</name>
<value>/data2/tmp/</value>
</property>创建并运行 stap 脚本,对所有在 /dev/vdc 上写操作的返回 IO Error#!/usr/bin/stap
probe vfs.write.return {
if (devname == "vdc") {
$return = -5
}
} $ stap -g io_errno.stp执行写入程序 demo,验证 stap 脚本有效$ dd if=/dev/zero of=test-1G-stap bs=1G count=1
$ hadoop fs -put test-1G s3a://<your-bucket>/返回结果:put: 输入/输出错误可以发现相关操作能正确抛出 IO 错误。模拟磁盘比特反转魔改 libfuse passthrough 中的 write 方法,并将 /data2/ 通过 fuse 挂载到 /mnt/passthrough$ mkdir -p /mnt/passthrough/
$ ./passthrough /mnt/passthrough/ -omodules=subdir -osubdir=/data2/ -oauto_unmount修改 core-sites.xml 中的 hadoop.tmp.dir 指向 /mnt/passthrough<property>
<name>fs.s3a.fast.upload</name>
<value>true</value>
</property>
<property>
<!-- 本地 buffer 缓存目录,不存在会创建 -->
<name>fs.s3a.buffer.dir</name>
<value>/mnt/passthrough/</value>
</property>执行写入程序 demo,验证上传内容的正确性。$ mkdir -p input output
$ dd if=/dev/zero of=input/test-1G-fuse bs=1G count=1
$ hadoop fs -put input/test-1G-fuse s3a://<your-bucket>/
$ hadoop fs -get s3a://<your-bucket>/test-1G-fuse output/
$ md5sum input/test-1G-fuse output/test-1G-fuse返回结果:cd573cfaace07e7949bc0c46028904ff input/test-1G-fuse
37eb6e664e706ea48281acbd4676569e output/test-1G-fuse可以发现,输入和输出的数据并不一致。综上,通过 Hadoop S3AFileSystem 写入可以发现磁盘 IO 问题并正确抛出异常,但无法发现磁盘比特反转问题。网络的问题既然磁盘写入有问题,那我们使用内存写入是否就一定可以避免踩坑呢?答案是不能,还可能有网络问题。Amazon S3 在 2008 年就曾因为网络问题导致的比特位反转引发过重大事故。后来,大家分析这种问题多发生于两端间隔多个路由器的情况,路由器可能因为硬件/内存故障导致单/多比特位反转或双字节交换,这种反转如果发生在 payload 区,则无法通过链路层、网络层、传输层的 checksum 检查出来。因此 Amazon S3 在这次事故中吸取的教训是,要通过在应用层给所有东西都添加 checksum 来保证数据正确性。让我们来做一个实验,来看看 S3 是怎么做到 Checksum all of the things的,又是否能防止网络比特反转或者网络丢包呢?模拟网络比特反转安装 mitmproxy$ pip3 install mitmproxy
$ mitmproxy --version
Mitmproxy: 5.3.0
Python: 3.6.8
OpenSSL: OpenSSL 1.1.1h 22 Sep 2020
Platform: Linux-3.10.0-1160.71.1.el7.x86_64-x86_64-with-centos-7.9.2009-Core利用 mitmdump 反向代理 s3a endpoint,并篡改其中的写请求。编写 addons.pyfrom mitmproxy import ctx, http
import json
import time
import os
class HookOssRequest:
def request(self, flow: http.HTTPFlow):
print("")
print("="*50)
print("FOR: " + flow.request.url)
print(flow.request.method + " " + flow.request.path + " " + flow.request.http_version)
print("-"*50 + "request headers:")
for k, v in flow.request.headers.items():
print("%-20s: %s" % (k.upper(), v))
if flow.request.host == "<your-bucket>.oss-cn-shanghai-internal.aliyuncs.com" and flow.request.method == "PUT":
clen = len(flow.request.content)
rbit = ord('a')
clist = list(flow.request.content)
origin = clist[clen - 1]
clist[clen - 1] = rbit
updated = clist[clen - 1]
flow.request.content = bytes(clist)
ctx.log.info("updated requesting content pos(" + str(clen - 1) + ") from " + str(chr(origin)) + " to " + str(chr(updated)))
def response(self, flow: http.HTTPFlow):
pass
addons = [
HookOssRequest()
]反向代理 http://.oss-cn-shanghai-internal.aliyuncs.com 到 http://localhost:8765$ mitmdump -s addons.py -p 8765 --set block_global=false --mode reverse:http://<your-bucket>.oss-cn-shanghai-internal.aliyuncs.com修改 core-sites.xml 中的 fs.s3a.endpoint 指向 localhost:8765,并关闭ssl。<property>
<name>fs.s3a.connection.ssl.enabled</name>
<value>false</value>
</property>
<property>
<name>fs.s3a.fast.upload</name>
<value>true</value>
</property>执行写入程序 demo,验证上传内容的正确性$ mkdir -p input output
$ dd if=/dev/zero of=input/test-100M-proxy bs=$(( 100*1024*1024 + 1 )) count=1
$ hadoop fs -put input/test-100M-proxy s3a://<your-bucket>/返回结果:xx/xx/xx xx:xx:xx WARN s3a.S3ABlockOutputStream: Transfer failure of block FileBlock{index=2, destFile=/data/hadoop/hadoop-2.8.5/tmp/s3a/s3ablock-0002-6832685202941984333.tmp, state=Upload, dataSize=1, limit=104857600}
xx/xx/xx xx:xx:xx WARN s3a.S3ABlockOutputStream: Transfer failure of block FileBlock{index=1, destFile=/data/hadoop/hadoop-2.8.5/tmp/s3a/s3ablock-0001-635596269039598032.tmp, state=Closed, dataSize=104857600, limit=104857600}
put: Multi-part upload with id '14ABE04E57114D0D9D8DBCFE4CB9366E' to test-100M-proxy._COPYING_ on test-100M-proxy._COPYING_: com.amazonaws.AmazonClientException: Unable to verify integrity of data upload. Client calculated content hash (contentMD5: 93B885ADFE0DA089CDF634904FD59F71 in hex) didn't match hash (etag: 0CC175B9C0F1B6A831C399E269772661 in hex) calculated by Amazon S3. You may need to delete the data stored in Amazon S3. (bucketName: <your-bucket>, key: test-100M-proxy._COPYING_, uploadId: 14ABE04E57114D0D9D8DBCFE4CB9366E, partNumber: 2, partSize: 1): Unable to verify integrity of data upload. Client calculated content hash (contentMD5: 93B885ADFE0DA089CDF634904FD59F71 in hex) didn't match hash (etag: 0CC175B9C0F1B6A831C399E269772661 in hex) calculated by Amazon S3. You may need to delete the data stored in Amazon S3. (bucketName: <your-bucket>, key: test-100M-proxy._COPYING_, uploadId: 14ABE04E57114D0D9D8DBCFE4CB9366E, partNumber: 2, partSize: 1)可见,Amazon S3 在 header 签名中强制对每个 upload part 的 payload 做了 Content-MD5 的校验,能够有效检测出网络比特反转。模拟网络丢包之前的测试验证了, S3 使用 Content-MD5 的校验可以保证单个请求的正确性,但在写一些大文件,或是涉及 JobCommitter 的作业中,往往会使用 multipart upload 来进行并发上传。而网络丢包也是一种常见的问题。于是,接下来我们来验证下,如果上传过程中其中一个 part 丢失,是否会给上传结果造成影响。同样使用 mitmproxy 来模拟丢包利用 mitmdump 反向代理 s3a endpoint,并丢弃其中 part2 的请求。编写 addons.pyfrom mitmproxy import ctx, http
import json
import time
import os
class HookOssRequest:
def request(self, flow: http.HTTPFlow):
print("")
print("="*50)
print("FOR: " + flow.request.url)
print(flow.request.method + " " + flow.request.path + " " + flow.request.http_version)
print("-"*50 + "request headers:")
for k, v in flow.request.headers.items():
print("%-20s: %s" % (k.upper(), v))
if flow.request.host == "<your-bucket>.oss-cn-shanghai-internal.aliyuncs.com" and flow.request.method == "PUT":
if "partNumber=2" in flow.request.path:
flow.response = http.HTTPResponse.make(
200, # (optional) status code
b"Hello World", # (optional) content
{"Content-Type": "text/html"}, # (optional) headers
)
ctx.log.info("drop part-2 request!")
ctx.log.info("requesting length:" + str(len(flow.request.content)))
def response(self, flow: http.HTTPFlow):
pass
addons = [
HookOssRequest()
]反向代理 http://.oss-cn-shanghai-internal.aliyuncs.com 到 http://localhost:8765$ mitmdump -s addons.py -p 8765 --set block_global=false --mode reverse:http://<your-bucket>.oss-cn-shanghai-internal.aliyuncs.com同样修改 core-sites.xml 中的 fs.s3a.endpoint 指向 localhost:8765,并关闭ssl。<property>
<name>fs.s3a.connection.ssl.enabled</name>
<value>false</value>
</property>
<property>
<name>fs.s3a.fast.upload</name>
<value>true</value>
</property>执行写入程序 demo,验证上传内容的正确性$ mkdir -p input output
$ dd if=/dev/zero of=input/test-100M-proxy bs=$(( 100*1024*1024 + 1 )) count=1
$ hadoop fs -put input/test-100M-proxy s3a://<your-bucket>/
xx/xx/xx xx:xx:x WARN s3a.S3ABlockOutputStream: Transfer failure of block FileBlock{index=2, destFile=/data/hadoop/hadoop-2.8.5/tmp/s3a/s3ablock-0002-2063629354855241099.tmp, state=Upload, dataSize=1, limit=104857600}
put: Multi-part upload with id 'D58303E74A5F4E6D8A27DD112297D0BE' to test-100M-proxy._COPYING_ on test-100M-proxy._COPYING_: com.amazonaws.AmazonClientException: Unable to verify integrity of data upload. Client calculated content hash (contentMD5: 93B885ADFE0DA089CDF634904FD59F71 in hex) didn't match hash (etag: null in hex) calculated by Amazon S3. You may need to delete the data stored in Amazon S3. (bucketName: <your-bucket>, key: test-100M-proxy._COPYING_, uploadId: D58303E74A5F4E6D8A27DD112297D0BE, partNumber: 2, partSize: 1): Unable to verify integrity of data upload. Client calculated content hash (contentMD5: 93B885ADFE0DA089CDF634904FD59F71 in hex) didn't match hash (etag: null in hex) calculated by Amazon S3. You may need to delete the data stored in Amazon S3. (bucketName: <your-bucket>, key: test-100M-proxy._COPYING_, uploadId: D58303E74A5F4E6D8A27DD112297D0BE, partNumber: 2, partSize: 1)可见,Amazon S3 在 close 请求中通过 CompleteMultipartUpload 对每个上传的 Part 做了检查,能够发现丢失的请求。校验算法的选择上文已经证明了校验码的不可或缺性,而且可以看到 Amazon S3 默认采用了 MD5 作为校验码。那就是最优的选择了吗?让我们来看看还有没有别的选择。数据摘要算法MD5、SHA-1、SHA-256、SHA-512都是数据摘要算法,均被广泛作为密码的散列函数。但由于MD5、SHA-1已经被证明为不安全的算法,目前建议使用较新的SHA-256和SHA-512。所有算法的输入均可以是不定长的数据。MD5输出是16字节(128位),SHA-1输出为20字节(160位),SHA-256为32字节(256位),SHA-512为64字节(512位)。可以看到,SHA算法的输出长度更长,因此更难发生碰撞,数据也更为安全。但运算速度与MD5相比,也更慢。循环冗余校验循环冗余校验又称 CRC(Cyclic redundancy check),将待发送的比特串看做是系数为 0 或者 1 的多项式。M = 1001010M(x) = 1*x^6 + 0*x^5 + 0*x^4 + 1*x^3 + 0*x^2 + 1*x^1 + 0*x^0M(x) = x^6 + x^3 + xCRC 编码时,发送方和接收方必须预先商定一个生成多项式 G(x)。发送方将比特串和生成多项式 G(x) 进行运算得到校验码,在比特串尾附加校验码,使得带校验码的比特串的多项式能被 G(x) 整除。接收方接收到后,除以 G(x),若有余数,则传输有错。校验算法的开销CRC算法的优点是算法实现相对简单、运算速度较快。而且错误检错能力很强,因此被广泛应用于通信数据校验。我们做了一些简单的benchmark以供参考:CRC32 > CRC64 > MD5 > SHA-1 > SHA-512 > SHA-256校验算法单次操作耗时BenchmarkMD5_100MB-8175423280 ns/opBenchmarkSHA1_100MB-8176478051 ns/opBenchmarkSHA256_100MB-8344191216 ns/opBenchmarkSHA512_100MB-8226938072 ns/opBenchmarkCRC32IEEE_100MB-810500107 ns/opBenchmarkCRC32Castagnoli_100MB-812991050 ns/opBenchmarkCRC64_100MB-8 86377178 ns/op而 OSS 支持的校验算法有 MD5 和 CRC64,那么同样的场景下,我们会优先选择 CRC64 替代 MD5。阿里云EMR JindoSDK 的最佳实践在总结了 S3AFileSystem 做法中的优缺点,并结合 OSS 自身提供的一些功能取长补短后,阿里云EMR JindoSDK 得出了自己的最佳实践。JindoSDK 实现的 JindoOutputStream 支持了两种校验方式,一种是请求级别的校验,一种是文件块级别的校验。请求级别的校验,默认关闭。需要打开时,配置 fs.oss.checksum.md5.enable 为 true 即可。配置好之后,客户端会在块级别的请求(PutObject/MultipartUpload)Header 中添加 Payload 的 Content-MD5。如果服务端计算 Payload 的 md5 与 客户端提供的不符,则客户端会重试。文件块级别的校验,默认打开。需要关闭时,需要配置 fs.oss.checksum.crc64.enable 为 false。则是在写入流一开始就在内存中同步计算传入 Buffer 的 CRC64,并在文件块落盘时和服务端计算返回的 CRC64 进行比较。使用最新的 jindosdk-4.6.2 版本与 S3AFileSystem 在数据湖写入路径上,综合对比的结果如下:场景S3AFileSystemJindoOssFileSystem磁盘 IO 问题抛出异常java.io.IOException抛出异常java.io.IOException磁盘比特反转未抛出异常抛出异常java.io.IOException网络比特反转抛出异常org.apache.hadoop.fs.s3a.AWSClientIOException抛出异常java.io.IOException 网络丢包抛出异常org.apache.hadoop.fs.s3a.AWSClientIOException抛出异常java.io.IOException写一个 5G 文件的耗时13.375s6.849s可以看到 EMR JindoSDK 在写 OSS 时,不仅有着相比 S3AFileSystem 更完善的错误检查,性能也更为优异。总结与展望数据湖存储的安全写入,必须要能考虑到内存、磁盘、网络的不可靠性。同时,也要结合存储介质本身的特性,选择合适的校验算法。熟悉数据写入完整链路,全面地考虑各种可能遇到的问题,并提供完善的测试方案验证可行性,才算有始有终。阿里云EMR JindoSDK 通过以上方式形成了自己的最佳实践,不仅保证了对象存储写入链路的安全性,同样也支持了EMR JindoFS服务(OSS-HDFS)的写入链路。虽然 OSS-HDFS 中的一个文件可以对应 OSS 上的多个对象,但是在写入 OSS 时,底层复用了同一套实现。因此,在使用时也不需要做额外的适配,完全可以共用相同的配置项。未来我们还将结合 OSS-HDFS,提供在数据随机读场景的安全性校验,而这是对象存储本身目前无法做到的。附录一:测试 S3A 的配置方式core-sites.xml<property>
<name>fs.s3a.impl</name>
<value>org.apache.hadoop.fs.s3a.S3AFileSystem</value>
</property>
<property>
<name>fs.AbstractFileSystem.s3a.impl</name>
<value>org.apache.hadoop.fs.s3a.S3A</value>
</property>
<property>
<name>fs.s3a.access.key</name>
<value>xxx</value>
</property>
<property>
<name>fs.s3a.secret.key</name>
<value>xx</value>
</property>
<property>
<name>fs.s3a.endpoint</name>
<value>localhost:8765</value>
</property>
<property>
<name>fs.s3a.connection.ssl.enabled</name>
<value>false</value>
</property>
<property>
<name>fs.s3a.fast.upload</name>
<value>true</value>
</property>
<property>
<!-- 本地 buffer 缓存目录,不存在会创建 -->
<name>fs.s3a.buffer.dir</name>
<value>/mnt/passthrough/</value>
</property>附录二:测试EMR JindoSDK 的配置方式core-sites.xml<property>
<name>fs.AbstractFileSystem.oss.impl</name>
<value>com.aliyun.jindodata.oss.OSS</value>
</property>
<property>
<name>fs.oss.impl</name>
<value>com.aliyun.jindodata.oss.JindoOssFileSystem</value>
</property>
<property>
<name>fs.oss.accessKeyId</name>
<value>xxx</value>
</property>
<property>
<name>fs.oss.accessKeySecret</name>
<value>xxx</value>
</property>
<property>
<name>fs.oss.endpoint</name>
<!-- 阿里云 ECS 环境下推荐使用内网 OSS Endpoint,即 oss-cn-xxx-internal.aliyuncs.com -->
<value>oss-cn-xxx.aliyuncs.com</value>
</property>
<property>
<!-- 客户端写入时的临时文件目录,可配置多个(逗号隔开),会轮流写入,多用户环境需配置可读写权限 -->
<name>fs.oss.tmp.data.dirs</name>
<value>/data2/tmp/</value>
</property>
<property>
<!-- 是否使用二级域名写入
打开后 <your-bucket>.oss-cn-xxx-internal.aliyuncs.com/<your-dir>
会变为 oss-cn-xxx-internal.aliyuncs.com/<your-bucket>/<your-dir> -->
<name>fs.oss.second.level.domain.enable</name>
<value>true</value>
</property>log4j.propertieslog4j.logger.com.aliyun.jindodata=INFO
log4j.logger.com.aliyun.jindodata.common.FsStats=INFOmitmproxy获取 endpoint ipping oss-cn-shanghai-internal.aliyuncs.com
64 bytes from xxx.xxx.xxx.xx (xxx.xxx.xxx.xx): icmp_seq=1 ttl=102 time=0.937 ms将 addons.py 中使用 ip 代替 .oss-cn-shanghai-internal.aliyuncs.com if flow.request.host == "xxx.xxx.xxx.xx" and flow.request.method == "PUT":反向代理时也使用 ip 代替 .oss-cn-shanghai-internal.aliyuncs.commitmdump -s addons.py -p 8765 --set block_global=false --mode reverse:http://xxx.xxx.xxx.xx:80欢迎感兴趣的朋友加入钉钉交流群(钉钉搜索群号33413498 或 钉钉扫描下方二维码)
高德Go生态的服务稳定性建设|性能优化的实战总结
本文共同作者:阳迪、联想、君清前言go语言凭借着优秀的性能,简洁的编码风格,极易使用的协程等优点,逐渐在各大互联网公司中流行起来。而高德业务使用go语言已经有3年时间了,随着高德业务的发展,go语言生态也日趋完善,今后会有越来越多新的go服务出现。在任何时候,保障服务的稳定性都是首要的,go服务也不例外,而性能优化作为保障服务稳定性,降本增效的重要手段之一,在高德go服务日益普及的当下显得愈发重要。此时此刻,我们将过去go服务开发中的性能调优经验进行总结和沉淀,为您呈上这篇精心准备的go性能调优指南。通过本文您将收获以下内容: 从理论的角度,和你一起捋清性能优化的思路,制定最合适的优化方案。推荐几款go语言性能分析利器,与你一起在性能优化的路上披荆斩棘。总结归纳了众多go语言中常用的性能优化小技巧,总有一个你能用上。基于高德go服务百万级QPS实践,分享几个性能优化实战案例,让性能优化不再是纸上谈兵。1. 性能调优-理论篇1.1 衡量指标优化的第一步是先衡量一个应用性能的好坏,性能良好的应用自然不必费心优化,性能较差的应用,则需要从多个方面来考察,找到木桶里的短板,才能对症下药。那么如何衡量一个应用的性能好坏呢?最主要的还是通过观察应用对核心资源的占用情况以及应用的稳定性指标来衡量。所谓核心资源,就是相对稀缺的,并且可能会导致应用无法正常运行的资源,常见的核心资源如下:cpu:对于偏计算型的应用,cpu往往是影响性能好坏的关键,如果代码中存在无限循环,或是频繁的线程上下文切换,亦或是糟糕的垃圾回收策略,都将导致cpu被大量占用,使得应用程序无法获取到足够的cpu资源,从而响应缓慢,性能变差。内存:内存的读写速度非常快,往往不是性能的瓶颈,但是内存相对来说容量有限切价格昂贵,如果应用大量分配内存而不及时回收,就会造成内存溢出或泄漏,应用无法分配新的内存,便无法正常运行,这将导致很严重的事故。带宽:对于偏网络I/O型的应用,例如网关服务,带宽的大小也决定了应用的性能好坏,如果带宽太小,当系统遇到大量并发请求时,带宽不够用,网络延迟就会变高,这个虽然对服务端可能无感知,但是对客户端则是影响甚大。磁盘:相对内存来说,磁盘价格低廉,容量很大,但是读写速度较慢,如果应用频繁的进行磁盘I/O,那性能可想而知也不会太好。以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:异常率:也叫错误率,一般分两种,执行超时和应用panic。panic会导致应用不可用,虽然服务通常都会配置相应的重启机制,确保偶然的应用挂掉后能重启再次提供服务,但是经常性的panic,会导致应用频繁的重启,减少了应用正常提供服务的时间,整体性能也就变差了。异常率是非常重要的指标,服务的稳定和可用是一切的前提,如果服务都不可用了,还谈何性能优化。响应时间(RT):包括平均响应时间,百分位(top percentile)响应时间。响应时间是指应用从收到请求到返回结果后的耗时,反应的是应用处理请求的快慢。通常平均响应时间无法反应服务的整体响应情况,响应慢的请求会被响应快的请求平均掉,而响应慢的请求往往会给用户带来糟糕的体验,即所谓的长尾请求,所以我们需要百分位响应时间,例如tp99响应时间,即99%的请求都会在这个时间内返回。吞吐量:主要指应用在一定时间内处理请求/事务的数量,反应的是应用的负载能力。我们当然希望在应用稳定的情况下,能承接的流量越大越好,主要指标包括QPS(每秒处理请求数)和QPM(每分钟处理请求数)。1.2 制定优化方案明确了性能指标以后,我们就可以评估一个应用的性能好坏,同时也能发现其中的短板并对其进行优化。但是做性能优化,有几个点需要提前注意:第一,不要反向优化。比如我们的应用整体占用内存资源较少,但是rt偏高,那我们就针对rt做优化,优化完后,rt下降了30%,但是cpu使用率上升了50%,导致一台机器负载能力下降30%,这便是反向优化。性能优化要从整体考虑,尽量在优化一个方面时,不影响其他方面,或是其他方面略微下降。第二,不要过度优化。如果应用性能已经很好了,优化的空间很小,比如rt的tp99在2ms内,继续尝试优化可能投入产出比就很低了,不如将这些精力放在其他需要优化的地方上。由此可见,在优化之前,明确想要优化的指标,并制定合理的优化方案是很重要的。常见的优化方案有以下几种:优化代码有经验的程序员在编写代码时,会时刻注意减少代码中不必要的性能消耗,比如使用strconv而不是fmt.Sprint进行数字到字符串的转化,在初始化map或slice时指定合理的容量以减少内存分配等。良好的编程习惯不仅能使应用性能良好,同时也能减少故障发生的几率。总结下来,常用的代码优化方向有以下几种:提高复用性,将通用的代码抽象出来,减少重复开发。池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。算法优化,使用时间复杂度更低的算法。使用设计模式设计模式是对代码组织形式的抽象和总结,代码的结构对应用的性能有着重要的影响,结构清晰,层次分明的代码不仅可读性好,扩展性高,还能避免许多潜在的性能问题,帮助开发人员快速找到性能瓶颈,进行专项优化,为服务的稳定性提供保障。常见的对性能有所提升的设计模式例如单例模式,我们可以在应用启动时将需要的外部依赖服务用单例模式先初始化,避免创建太多重复的连接。空间换时间或时间换空间在优化的前期,可能一个小的优化就能达到很好的效果。但是优化的尽头,往往要面临抉择,鱼和熊掌不可兼得。性能优秀的应用往往是多项资源的综合利用最优。为了达到综合平衡,在某些场景下,就需要做出一些调整和牺牲,常用的方法就是空间换时间或时间换空间。比如在响应时间优先的场景下,把需要耗费大量计算时间或是网络i/o时间的中间结果缓存起来,以提升后续相似请求的响应速度,便是空间换时间的一种体现。使用更好的三方库在我们的应用中往往会用到很多开源的第三方库,目前在github上的go开源项目就有173万+。有很多go官方库的性能表现并不佳,比如go官方的日志库性能就一般,下面是zap发布的基准测试信息(记录一条消息和10个字段的性能表现)。PackageTimeTime % to zapObjects Allocated⚡️ zap862 ns/op+0%5 allocs/op⚡️ zap (sugared)1250 ns/op+45%11 allocs/opzerolog4021 ns/op+366%76 allocs/opgo-kit4542 ns/op+427%105 allocs/opapex/log26785 ns/op+3007%115 allocs/oplogrus29501 ns/op+3322%125 allocs/oplog1529906 ns/op+3369%122 allocs/op从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。2. 性能调优-工具篇当我们找到应用的性能短板,并针对短板制定相应优化方案,最后按照方案对代码进行优化之后,我们怎么知道优化是有效的呢?直接将代码上线,观察性能指标的变化,风险太大了。此时我们需要有好用的性能分析工具,帮助我们检验优化的效果,下面将为大家介绍几款go语言中性能分析的利器。2.1 benchmarkGo语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,benchmark可以帮助我们评估代码的性能表现,主要方式是通过在一定时间(默认1秒)内重复运行测试代码,然后输出执行次数和内存分配结果。下面我们用一个简单的例子来验证一下,strconv是否真的比fmt.Sprint快。首先我们来编写一段基准测试的代码,如下:package main
import (
"fmt"
"strconv"
"testing"
)
func BenchmarkStrconv(b *testing.B) {
for n := 0; n < b.N; n++ {
strconv.Itoa(n)
}
}
func BenchmarkFmtSprint(b *testing.B) {
for n := 0; n < b.N; n++ {
fmt.Sprint(n)
}
}我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 41988014 27.41 ns/op
BenchmarkFmtSprint-12 13738172 81.19 ns/op
ok main 7.039s可以看到strconv每次执行只用了27.41纳秒,而fmt.Sprint则是81.19纳秒,strconv的性能是fmt.Sprint的三倍,那为什么strconv要更快呢?会不会是这次运行时间太短呢?为了公平起见,我们决定让他们再比赛一轮,这次我们延长比赛时间,看看结果如何。通过go test -bench . -benchtime=5s 命令,我们可以把测试时间延长到5秒,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 211533207 31.60 ns/op
BenchmarkFmtSprint-12 69481287 89.58 ns/op
PASS
ok main 18.891s结果有些变化,strconv每次执行的时间上涨了4ns,但变化不大,差距仍有2.9倍。但是我们仍然不死心,我们决定让他们一次跑三轮,每轮5秒,三局两胜。通过go test -bench . -benchtime=5s -count=3 命令,我们可以把测试进行3轮,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 217894554 31.76 ns/op
BenchmarkStrconv-12 217140132 31.45 ns/op
BenchmarkStrconv-12 219136828 31.79 ns/op
BenchmarkFmtSprint-12 70683580 89.53 ns/op
BenchmarkFmtSprint-12 63881758 82.51 ns/op
BenchmarkFmtSprint-12 64984329 82.04 ns/op
PASS
ok main 54.296s结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?通过go test -bench . -benchmem 这个命令我们可以看到两个方法的内存分配情况,结果如下:goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStrconv-12 43700922 27.46 ns/op 7 B/op 0 allocs/op
BenchmarkFmtSprint-12 143412 80.88 ns/op 16 B/op 2 allocs/op
PASS
ok main 7.031s可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?通过查看strconv的代码,我们发现,对于小于100的数字,strconv是直接通过digits和smallsString这两个常量进行转换的,而大于等于100的数字,则是通过不断除以100取余,然后再找到余数对应的字符串,把这些余数的结果拼起来进行转换的。const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
const smallsString = "00010203040506070809" +
"10111213141516171819" +
"20212223242526272829" +
"30313233343536373839" +
"40414243444546474849" +
"50515253545556575859" +
"60616263646566676869" +
"70717273747576777879" +
"80818283848586878889" +
"90919293949596979899"
// small returns the string for an i with 0 <= i < nSmalls.
func small(i int) string {
if i < 10 {
return digits[i : i+1]
}
return smallsString[i*2 : i*2+2]
}
func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string) {
...
for j := 4; j > 0; j-- {
is := us % 100 * 2
us /= 100
i -= 2
a[i+1] = smallsString[is+1]
a[i+0] = smallsString[is+0]
}
...
}而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。// fmtInteger formats signed and unsigned integers.
func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) {
...
switch base {
case 10:
for u >= 10 {
i--
next := u / 10
buf[i] = byte('0' + u - next*10)
u = next
}
...
}benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。2.2 pprof2.2.1 使用介绍pprof是go语言官方提供的profile工具,支持可视化查看性能报告,功能十分强大。pprof基于定时器(10ms/次)对运行的go程序进行采样,搜集程序运行时的堆栈信息,包括CPU时间、内存分配等,最终生成性能报告。pprof有两个标准库,使用的场景不同:runtime/pprof 通过在代码中显式的增加触发和结束埋点来收集指定代码块运行时数据生成性能报告。net/http/pprof 是对runtime/pprof的二次封装,基于web服务运行,通过访问链接触发,采集服务运行时的数据生成性能报告。runtime/pprof的使用方法如下:package main
import (
"os"
"runtime/pprof"
"time"
)
func main() {
w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644)
pprof.StartCPUProfile(w)
time.Sleep(time.Second)
pprof.StopCPUProfile()
}我们也可以使用另外一种方法,net/http/pprof:package main
import (
"net/http"
_ "net/http/pprof"
)
func main() {
err := http.ListenAndServe(":6060", nil)
if err != nil {
panic(err)
}
}将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:点击profile就可以下载cpu profile文件。那我们如何查看我们的性能报告呢? pprof支持两种查看模式,终端和web界面,注意: 想要查看可视化界面需要提前安装graphviz。这里我们以web界面为例,在终端内我们输入如下命令:go tool pprof -http :6060 test_cpu就会在浏览器里打开一个页面,内容如下:从界面左上方VIEW栏下,我们可以看到,pprof支持Flame Graph,dot Graph和Top等多种视图,下面我们将一一介绍如何阅读这些视图。2.2.1 火焰图 Flame Graph如何阅读首先,推荐直接阅读火焰图,在查函数耗时场景,这个比较直观;最简单的:横条越长,资源消耗、占用越多; 注意:每一个function 的横条虽然很长,但可能是他的下层“子调用”耗时产生的,所以一定要关注“下一层子调用”各自的耗时分布;每个横条支持点击下钻能力,可以更详细的分析子层的耗时占比。2.2.2 dot Graph 图如何阅读英文原文在这里:https://github.com/google/pprof/blob/master/doc/README.md节点颜色:红色表示耗时多的节点;绿色表示耗时少的节点;灰色表示耗时几乎可以忽略不计(接近零);节点字体大小 :字体越大,表示占“上层函数调用”比例越大;(其实上层函数自身也有耗时,没包含在此)字体越小,表示占“上层函数调用”比例越小;线条(边)粗细:线条越粗,表示消耗了更多的资源;反之,则越少;线条(边)颜色:颜色越红,表示性能消耗占比越高;颜色越绿,表示性能消耗占比越低;灰色,表示性能消耗几乎可以忽略不计;虚线:表示中间有一些节点被“移除”或者忽略了;(一般是因为耗时较少所以忽略了) 实线:表示节点之间直接调用 内联边标记:被调用函数已经被内联到调用函数中(对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。)2.2.3 TOP 表如何阅读flat:当前函数,运行耗时(不包含内部调用其他函数的耗时)flat%:当前函数,占用的 CPU 运行耗时总比例(不包含外部调用函数)sum%:当前行的flat%与上面所有行的flat%总和。cum:当前函数加上它内部的调用的运行总耗时(包含内部调用其他函数的耗时)cum%:同上的 CPU 运行耗时总比例2.3 tracepprof已经有了对内存和CPU的分析能力,那trace工具有什么不同呢?虽然pprof的CPU分析器,可以告诉你什么函数占用了最多的CPU时间,但它并不能帮助你定位到是什么阻止了goroutine运行,或者在可用的OS线程上如何调度goroutines。这正是trace真正起作用的地方。我们需要更多关于Go应用中各个goroutine的执行情况的更为详细的信息,可以从P(goroutine调度器概念中的processor)和G(goroutine调度器概念中的goroutine)的视角完整的看到每个P和每个G在Tracer开启期间的全部“所作所为”,对Tracer输出数据中的每个P和G的行为分析并结合详细的event数据来辅助问题诊断的。Tracer可以帮助我们记录的详细事件包含有:与goroutine调度有关的事件信息:goroutine的创建、启动和结束;goroutine在同步原语(包括mutex、channel收发操作)上的阻塞与解锁。与网络有关的事件:goroutine在网络I/O上的阻塞和解锁;与系统调用有关的事件:goroutine进入系统调用与从系统调用返回;与垃圾回收器有关的事件:GC的开始/停止,并发标记、清扫的开始/停止。Tracer主要也是用于辅助诊断这三个场景下的具体问题的:并行执行程度不足的问题:比如没有充分利用多核资源等;因GC导致的延迟较大的问题;Goroutine执行情况分析,尝试发现goroutine因各种阻塞(锁竞争、系统调用、调度、辅助GC)而导致的有效运行时间较短或延迟的问题。2.3.1 trace性能报告打开trace性能报告,首页信息包含了多维度数据,如下图:View trace:以图形页面的形式渲染和展示tracer的数据,这也是我们最为关注/最常用的功能Goroutine analysis:以表的形式记录执行同一个函数的多个goroutine的各项trace数据Network blocking profile:用pprof profile形式的调用关系图展示网络I/O阻塞的情况Synchronization blocking profile:用pprof profile形式的调用关系图展示同步阻塞耗时情况Syscall blocking profile:用pprof profile形式的调用关系图展示系统调用阻塞耗时情况Scheduler latency profile:用pprof profile形式的调用关系图展示调度器延迟情况User-defined tasks和User-defined regions:用户自定义trace的task和regionMinimum mutator utilization:分析GC对应用延迟和吞吐影响情况的曲线图通常我们最为关注的是View trace和Goroutine analysis,下面将详细说说这两项的用法。2.3.2 view trace如果Tracer跟踪时间较长,trace会将View trace按时间段进行划分,避免触碰到trace-viewer的限制:View trace使用快捷键来缩放时间线标尺:w键用于放大(从秒向纳秒缩放),s键用于缩小标尺(从纳秒向秒缩放)。我们同样可以通过快捷键在时间线上左右移动:s键用于左移,d键用于右移。(游戏快捷键WASD)采样状态这个区内展示了三个指标:Goroutines、Heap和Threads,某个时间点上的这三个指标的数据是这个时间点上的状态快照采样:Goroutines:某一时间点上应用中启动的goroutine的数量,当我们点击某个时间点上的goroutines采样状态区域时(我们可以用快捷键m来准确标记出那个时间点),事件详情区会显示当前的goroutines指标采样状态:Heap指标则显示了某个时间点上Go应用heap分配情况(包括已经分配的Allocated和下一次GC的目标值NextGC):Threads指标显示了某个时间点上Go应用启动的线程数量情况,事件详情区将显示处于InSyscall(整阻塞在系统调用上)和Running两个状态的线程数量情况:P视角区这里将View trace视图中最大的一块区域称为“P视角区”。这是因为在这个区域,我们能看到Go应用中每个P(Goroutine调度概念中的P)上发生的所有事件,包括:EventProcStart、EventProcStop、EventGoStart、EventGoStop、EventGoPreempt、Goroutine辅助GC的各种事件以及Goroutine的GC阻塞(STW)、系统调用阻塞、网络阻塞以及同步原语阻塞(mutex)等事件。除了每个P上发生的事件,我们还可以看到以单独行显示的GC过程中的所有事件。事件详情区点选某个事件后,关于该事件的详细信息便会在这个区域显示出来,事件详情区可以看到关于该事件的详细信息:Title:事件的可读名称;Start:事件的开始时间,相对于时间线上的起始时间;Wall Duration:这个事件的持续时间,这里表示的是G1在P4上此次持续执行的时间;Start Stack Trace:当P4开始执行G1时G1的调用栈;End Stack Trace:当P4结束执行G1时G1的调用栈;从上面End Stack Trace栈顶的函数为runtime.asyncPreempt来看,该Goroutine G1是被强行抢占了,这样P4才结束了其运行;Incoming flow:触发P4执行G1的事件;Outgoing flow:触发G1结束在P4上执行的事件;Preceding events:与G1这个goroutine相关的之前的所有的事件;Follwing events:与G1这个goroutine相关的之后的所有的事件All connected:与G1这个goroutine相关的所有事件。2.3.3 Goroutine analysisGoroutine analysis提供了从G视角看Go应用执行的图景。与View trace不同,这次页面中最广阔的区域提供的G视角视图,而不再是P视角视图。在这个视图中,每个G都会对应一个单独的条带(和P视角视图一样,每个条带都有两行),通过这一条带可以按时间线看到这个G的全部执行情况。通常仅需在goroutine analysis的表格页面找出执行最快和最慢的两个goroutine,在Go视角视图中沿着时间线对它们进行对比,以试图找出执行慢的goroutine究竟出了什么问题。2.4 后记虽然pprof和trace有着非常强大的profile能力,但在使用过程中,仍存在以下痛点:获取性能报告麻烦:一般大家做压测,为了更接近真实环境性能态,都使用生产环境/pre环境进行。而出于安全考虑,生产环境内网一般和PC办公内网是隔离不通的,需要单独配置通路才可以获得生产环境内网的profile 文件下载到PC办公电脑中,这也有一些额外的成本;查看profile分析报告麻烦:之前大家在本地查看profile 分析报告,一般 go tool pprof -http=":8083" profile 命令在本地PC开启一个web service 查看,并且需要至少安装graphviz 等库。查看trace分析同样麻烦:查看go trace 的profile 信息来分析routine 锁和生命周期时,也需要类似的方式在本地PC执行命令 go tool trace mytrace.profile 分享麻烦:如果我想把自己压测的性能结果内容,分享个另一位同学,那只能把1中获取的性能报告“profile文件”通过钉钉发给被分享人。然而有时候本地profile文件比较多,一不小心就发错了,还不如截图,但是截图又没有了交互放大、缩小、下钻等能力。处处不给力!留存复盘麻烦:系统的性能分析就像一份病历,每每看到阶段性的压测报告,总结或者对照时,不禁要询问,做过了哪些优化和改造,病因病灶是什么,有没有共性,值不值得总结归纳,现在是不是又面临相似的性能问题?那么能不能开发一个平台工具,解决以上的这些痛点呢?目前在阿里集团内部,高德的研发同学已经通过对go官方库的定制开发,实现了go语言性能平台,解决了以上这些痛点,并在内部进行了开源。该平台已面向阿里集团,累计实现性能场景快照数万条的获取和分析,解决了很多的线上服务性能调试和优化问题,这里暂时不展开,后续有机会可以单独分享。3. 性能调优-技巧篇除了前面提到的尽量用strconv而不是fmt.Sprint进行数字到字符串的转化以外,我们还将介绍一些在实际开发中经常会用到的技巧,供各位参考。3.1 字符串拼接拼接字符串为了书写方便快捷,最常用的两个方法是运算符 + 和 fmt.Sprintf()运算符 + 只能简单地完成字符串之间的拼接,fmt.Sprintf() 其底层实现使用了反射,性能上会有所损耗。从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(<=5),拼接字符串推荐使用运算符 +,反之使用 fmt.Sprintf()。// 推荐:用+进行字符串拼接
func BenchmarkPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
s := "a" + "b"
_ = s
}
}
// 不推荐:用fmt.Sprintf进行字符串拼接
func BenchmarkFmt(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprintf("%s%s", "a", "b")
_ = s
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkPlus-12 1000000000 0.2658 ns/op 0 B/op 0 allocs/op
BenchmarkFmt-12 16559949 70.83 ns/op 2 B/op 1 allocs/op
PASS
ok main 5.908s3.2 提前指定容器容量在初始化slice时,尽量指定容量,这是因为当添加元素时,如果容量的不足,slice会重新申请一个更大容量的容器,然后把原来的元素复制到新的容器中。// 推荐:初始化时指定容量
func BenchmarkGenerateWithCap(b *testing.B) {
nums := make([]int, 0, 10000)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
// 不推荐:初始化时不指定容量
func BenchmarkGenerate(b *testing.B) {
nums := make([]int, 0)
for n := 0; n < b.N; n++ {
for i:=0; i < 10000; i++ {
nums = append(nums, i)
}
}
}
goos: darwin
goarch: amd64
pkg: main
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkGenerateWithCap-12 23508 336485 ns/op 476667 B/op 0 allocs/op
BenchmarkGenerate-12 22620 68747 ns/op 426141 B/op 0 allocs/op
PASS
ok main 16.628s3.3 遍历 []struct{} 使用下标而不是 range常用的遍历方式有两种,一种是for循环下标遍历,一种是for循环range遍历,这两种遍历在性能上是否有差异呢?让我们来一探究竟。针对[]int,我们来看看两种遍历有和差别吧func getIntSlice() []int {
nums := make([]int, 1024, 1024)
for i := 0; i < 1024; i++ {
nums[i] = i
}
return nums
}
// 用下标遍历[]int
func BenchmarkIndexIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for k := 0; k < len(nums); k++ {
tmp = nums[k]
}
_ = tmp
}
}
// 用range遍历[]int元素
func BenchmarkRangeIntSlice(b *testing.B) {
nums := getIntSlice()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var tmp int
for _, num := range nums {
tmp = num
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexIntSlice-12 3923230 270.2 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIntSlice-12 4518495 287.8 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 3.303s可以看到,在遍历[]int时,两种方式并无差别。我们再看看遍历[]struct{}的情况type Item struct {
id int
val [1024]byte
}
// 推荐:用下标遍历[]struct{}
func BenchmarkIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for j := 0; j < len(items); j++ {
tmp = items[j].id
}
_ = tmp
}
}
// 推荐:用range的下标遍历[]struct{}
func BenchmarkRangeIndexStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for k := range items {
tmp = items[k].id
}
_ = tmp
}
}
// 不推荐:用range遍历[]struct{}的元素
func BenchmarkRangeStructSlice(b *testing.B) {
var items [1024]Item
for i := 0; i < b.N; i++ {
var tmp int
for _, item := range items {
tmp = item.id
}
_ = tmp
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkIndexStructSlice-12 4413182 266.7 ns/op 0 B/op 0 allocs/op
BenchmarkRangeIndexStructSlice-12 4545476 269.4 ns/op 0 B/op 0 allocs/op
BenchmarkRangeStructSlice-12 33300 35444 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 5.282s可以看到,用for循环下标的方式性能都差不多,但是用range遍历数组里的元素时,性能则相差很多,前面两种方法是第三种方法的130多倍。主要原因是通过for k, v := range获取到的元素v实际上是原始值的一个拷贝。所以在面对复杂的struct进行遍历的时候,推荐使用下标。但是当遍历对象是复杂结构体的指针([]*struct{})时,用下标还是用range迭代元素的性能就差不多了。3.4 利用unsafe包避开内存copyunsafe包提供了任何类型的指针和 unsafe.Pointer 的相互转换及uintptr 类型和 unsafe.Pointer 可以相互转换,如下图unsafe包指针转换关系依据上述转换关系,其实除了string和[]byte的转换,也可以用于slice、map等的求长度及一些结构体的偏移量获取等,但是这种黑科技在一些情况下会带来一些匪夷所思的诡异问题,官方也不建议用,所以还是慎用,除非你确实很理解各种机制了,这里给出项目中实际用到的常规string和[]byte之间的转换,如下:func Str2bytes(s string) []byte {
x := (*[2]uintptr)(unsafe.Pointer(&s))
h := [3]uintptr{x[0], x[1], x[1]}
return *(*[]byte)(unsafe.Pointer(&h))
}
func Bytes2str(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
我们通过benchmark来验证一下是否性能更优:// 推荐:用unsafe.Pointer实现string到bytes
func BenchmarkStr2bytes(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = Str2bytes(s)
}
_ = bs
}
// 不推荐:用类型转换实现string到bytes
func BenchmarkStr2bytes2(b *testing.B) {
s := "testString"
var bs []byte
for n := 0; n < b.N; n++ {
bs = []byte(s)
}
_ = bs
}
// 推荐:用unsafe.Pointer实现bytes到string
func BenchmarkBytes2str(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = Bytes2str(bs)
}
_ = s
}
// 不推荐:用类型转换实现bytes到string
func BenchmarkBytes2str2(b *testing.B) {
bs := Str2bytes("testString")
var s string
b.ResetTimer()
for n := 0; n < b.N; n++ {
s = string(bs)
}
_ = s
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkStr2bytes-12 1000000000 0.2938 ns/op 0 B/op 0 allocs/op
BenchmarkStr2bytes2-12 38193139 28.39 ns/op 16 B/op 1 allocs/op
BenchmarkBytes2str-12 1000000000 0.2552 ns/op 0 B/op 0 allocs/op
BenchmarkBytes2str2-12 60836140 19.60 ns/op 16 B/op 1 allocs/op
PASS
ok demo/test 3.301s可以看到使用unsafe.Pointer比强制类型转换性能是要高不少的,从内存分配上也可以看到完全没有新的内存被分配。3.5 协程池go语言最大的特色就是很容易的创建协程,同时go语言的协程调度策略也让go程序可以最大化的利用cpu资源,减少线程切换。但是无限度的创建goroutine,仍然会带来问题。我们知道,一个go协程占用内存大小在2KB左右,无限度的创建协程除了会占用大量的内存空间,同时协程的切换也有不少开销,一次协程切换大概需要100ns,虽然相较于线程毫秒级的切换要优秀很多,但依然存在开销,而且这些协程最后还是需要GC来回收,过多的创建协程,对GC也是很大的压力。所以我们在使用协程时,可以通过协程池来限制goroutine数量,避免无限制的增长。限制协程的方式有很多,比如可以用channel来限制:var wg sync.WaitGroup
ch := make(chan struct{}, 3)
for i := 0; i < 10; i++ {
ch <- struct{}{}
wg.Add(1)
go func(i int) {
defer wg.Done()
log.Println(i)
time.Sleep(time.Second)
<-ch
}(i)
}
wg.Wait()这里通过限制channel长度为3,可以实现最多只有3个协程被创建的效果。当然也可以使用@烟渺实现的errgoup。使用方法如下:func Test_ErrGroupRun(t *testing.T) {
errgroup := WithTimeout(nil, 10*time.Second)
errgroup.SetMaxProcs(4)
for index := 0; index < 10; index++ {
errgroup.Run(nil, index, "test", func(context *gin.Context, i interface{}) (interface{},
error) {
t.Logf("[%s]input:%+v, time:%s", "test", i, time.Now().Format("2006-01-02 15:04:05"))
time.Sleep(2*time.Second)
return i, nil
})
}
errgroup.Wait()
}输出结果如下:=== RUN Test_ErrGroupRun
errgroup_test.go:23: [test]input:0, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:3, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:1, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:2, time:2022-12-04 17:31:29
errgroup_test.go:23: [test]input:4, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:5, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:6, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:7, time:2022-12-04 17:31:31
errgroup_test.go:23: [test]input:8, time:2022-12-04 17:31:33
errgroup_test.go:23: [test]input:9, time:2022-12-04 17:31:33
--- PASS: Test_ErrGroupRun (6.00s)
PASSerrgroup可以通过SetMaxProcs设定协程池的大小,从上面的结果可以看到,最多就4个协程在运行。3.6 sync.Pool 对象复用我们在代码中经常会用到json进行序列化和反序列化,举一个投放活动的例子,一个投放活动会有许多字段会转换为字节数组。type ActTask struct {
Id int64 `ddb:"id"` // 主键id
Status common.Status `ddb:"status"` // 状态 0=初始 1=生效 2=失效 3=过期
BizProd common.BizProd `ddb:"biz_prod"` // 业务类型
Name string `ddb:"name"` // 活动名
Adcode string `ddb:"adcode"` // 城市
RealTimeRuleByte []byte `ddb:"realtime_rule"` // 实时规则json
...
}
type RealTimeRuleStruct struct {
Filter []*struct {
PropertyId int64 `json:"property_id"`
PropertyCode string `json:"property_code"`
Operator string `json:"operator"`
Value []string `json:"value"`
} `json:"filter"`
ExtData [1024]byte `json:"ext_data"`
}
func (at *ActTask) RealTimeRule() *form.RealTimeRule {
if err := json.Unmarshal(at.RealTimeRuleByte, &at.RealTimeRuleStruct); err != nil {
return nil
}
return at.RealTimeRuleStruct
}以这里的实时投放规则为例,我们会将过滤规则反序列化为字节数组。每次json.Unmarshal都会申请一个临时的结构体对象,而这些对象都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。对于需要频繁创建并回收的对象,我们可以使用对象池来提升性能。sync.Pool可以将暂时不用的对象缓存起来,待下次需要的时候直接使用,不用再次经过内存分配,复用对象的内存,减轻 GC 的压力,提升系统的性能。sync.Pool的使用方法很简单,只需要实现 New 函数即可。对象池中没有对象时,将会调用 New 函数创建。var realTimeRulePool = sync.Pool{
New: func() interface{} {
return new(RealTimeRuleStruct)
},
}然后调用 Pool 的 Get() 和 Put() 方法来获取和放回池子中。rule := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(buf, rule)
realTimeRulePool.Put(rule)Get() 用于从对象池中获取对象,因为返回值是 interface{},因此需要类型转换。Put() 则是在对象使用完毕后,放回到对象池。接下来我们进行性能测试,看看性能如何var realTimeRule = []byte("{\\\"filter\\\":[{\\\"property_id\\\":2,\\\"property_code\\\":\\\"search_poiid_industry\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"yimei\\\"]},{\\\"property_id\\\":4,\\\"property_code\\\":\\\"request_page_id\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"all\\\"]}],\\\"white_list\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"white_list_for_adiu\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"j838ef77bf227chcl89888f3fb0946\\\",\\\"lb89bea9af558589i55559764bc83e\\\"]}],\\\"ipc_user_tag\\\":[{\\\"property_id\\\":1,\\\"property_code\\\":\\\"ipc_crowd_tag\\\",\\\"operator\\\":\\\"in\\\",\\\"value\\\":[\\\"test_20227041152_mix_ipc_tag\\\"]}],\\\"relation_id\\\":0,\\\"is_copy\\\":true}")
// 推荐:复用一个对象,不用每次都生成新的
func BenchmarkUnmarshalWithPool(b *testing.B) {
for n := 0; n < b.N; n++ {
task := realTimeRulePool.Get().(*RealTimeRuleStruct)
json.Unmarshal(realTimeRule, task)
realTimeRulePool.Put(task)
}
}
// 不推荐:每次都会生成一个新的临时对象
func BenchmarkUnmarshal(b *testing.B) {
for n := 0; n < b.N; n++ {
task := &RealTimeRuleStruct{}
json.Unmarshal(realTimeRule, task)
}
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkUnmarshalWithPool-12 3627546 319.4 ns/op 312 B/op 7 allocs/op
BenchmarkUnmarshal-12 2342208 490.8 ns/op 1464 B/op 8 allocs/op
PASS
ok demo/test 3.525s可以看到,两种方法在时间消耗上差不太多,但是在内存分配上差距明显,使用sync.Pool后内存占用仅为不使用的1/5。3.7 避免系统调用系统调用是一个很耗时的操作,在各种语言中都是,go也不例外,在go的GPM模型中,异步系统调用G会和MP分离,同步系统调用GM会和P分离,不管何种形式除了状态切换及内核态中执行操作耗时外,调度器本身的调度也耗时。所以在可以避免系统调用的地方尽量去避免// 推荐:不使用系统调用
func BenchmarkNoSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if configs.PUBLIC_KEY != nil {
}
}
})
}
// 不推荐:使用系统调用
func BenchmarkSytemcall(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if os.Getenv("PUBLIC_KEY") != "" {
}
}
})
}
goos: darwin
goarch: amd64
pkg: demo/test
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkNoSytemcall-12 1000000000 0.1495 ns/op 0 B/op 0 allocs/op
BenchmarkSytemcall-12 37224988 31.10 ns/op 0 B/op 0 allocs/op
PASS
ok demo/test 1.877s4. 性能调优-实战篇案例1: go协程创建数据库连接不释放导致内存暴涨应用背景感谢@路现提供的案例。遇到的问题及表象特征线上机器偶尔出现内存使用率超过百分之九十报警。分析思路及排查方向在报警触发时,通过直接拉取线上应用的profile文件,查看内存分配情况,我们看到内存分配主要产生在本地缓存的组件上。但是分析代码并没有发现存在内存泄露的情况,看着像是资源一直没有被释放,进一步分析goroutine的profile文件发现存在大量的goroutine未释放,表现在本地缓存击穿后回源数据库,对数据库的查询访问一直不释放。调优手段与效果最终通过排查,发现使用的数据库组件存在bug,在极端情况下会出现死锁的情况,导致数据库访问请求无法返回也无法释放。最终bug修复后升级数据库组件版本解决了问题。案例2: 优惠索引内存分配大,gc 耗时高应用背景感谢@梅东提供的案例。遇到的问题及表象特征接口tp99高,偶尔会有一些特别耗时的请求,导致用户的优惠信息展示不出来分析思路及排查方向通过直接在平台上抓包观察,我们发现使用的分配索引这个方法占用的堆内存特别高,通过 top 可以看到是排在第一位的我们分析代码,可以看到,获取城市索引的地方,每次都是重新申请了内存的,通过改动为返回指针,就不需要每次都单独申请内存了,核心代码改动:调优手段与效果修改后,上线观察,可以看到使用中的内存以及gc耗时都有了明显降低案例3:流量上涨导致cpu异常飙升应用背景感谢@君度提供的案例。遇到的问题及表象特征能量站v2接口和task-home-page接口流量较大时,会造成ab实验策略匹配时cpu飙升分析思路及排查方向调优手段与效果主要优化点如下:1、优化toEntity方法,简化为单独的ID()方法2、优化数组、map初始化结构3、优化adCode转换为string过程4、关闭过多的match log打印优化后profile:优化上线前后CPU的对比案例4: 内存对象未释放导致内存泄漏应用背景感谢@淳深提供的案例,提供案例的服务,日常流量峰值在百万qps左右,是高德内部十分重要的服务。此前该服务是由java实现的,后来用go语言进行重构,在重构完成切全量后,有许多性能优化的优秀案例,这里选取内存泄漏相关的一个案例分享给大家,希望对大家在自己服务进行内存泄漏问题排查时能提供参考和帮助。遇到的问题及表象特征go语言版本全量切流后,每天会对服务各项指标进行详细review,发现每日内存上涨约0.4%,如下图在go版本服务切全量前,从第一张图可以看到整个内存使用是平稳的,无上涨趋势,但是切go版本后,从第二张图可以看到,整个内存曲线呈上升趋势,遂认定内存泄漏,开始排查内存泄漏的“罪魁祸首”。分析思路及排查方向我们先到线上机器抓取当前时间的heap文件,间隔一天后再次抓取heap文件,通过pprof diff对比,我们发现time.NewTicker的内存占用增长了几十MB(由于未保留当时的heap文件,此处没有截图),通过调用栈信息,我们找到了问题的源头,来自中间件vipserver client的SrvHost方法,通过深扒vipserver client代码,我们发现,每个vipserver域名都会有一个对应的协程,这个协程每隔三秒钟就会新建一个ticker对象,且用过的ticker对象没有stop,也就不会释放相应的内存资源。而这个time.NewTicker会创建一个timer对象,这个对象会占用72字节内存。在服务运行一天的情况下,进过计算,该对象累计会在内存中占用约35.6MB,和上述内存每日增长0.4%正好能对上,我们就能断定这个内存泄漏来自这里。调优手段与效果知道是timer对象重复创建的问题后,只需要修改这部分的代码就好了,最新的vipserver client修改了此处的逻辑,如下修改完后,运行一段时间,内存运行状态平稳,已无内存泄漏问题。结语目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,希望本文能为正在使用go语言的同学在性能优化方面带来一些参考价值。在阿里集团内部,高德也是最早规模化使用go语言的团队之一,目前高德线上运行的go服务已经达到近百个,整体qps已突破百万量级。在使用go语言的同时,高德也为集团内go语言生态建设做出了许多贡献,包括开发支持阿里集团常见的中间件(比如配置中心-Diamond、分布式RPC服务框架-HSF、服务发现-Vipserver、消息队列-MetaQ、流量控制-Sentinel、日志追踪-Eagleeye等)go语言版本,并被阿里中间件团队官方收录。但是go语言生态建设仍然有很长的道路要走,希望能有更多对go感兴趣的同学能够加入我们,一起参与阿里的go生态建设,乃至为互联网业界的go生态发展添砖加瓦。
从输入URL到页面呈现的全过程
当用户在浏览器的地址栏中输入 URL 并点击回车后,页面是如何呈现的。简单来说,当用户在浏览器的地址栏中输入 URL 并点击回车后,浏览器从服务端获取资源,然后将内容显示在页面上。这个过程经过了:浏览器缓存 -> DNS 域名解析 -> URL 编码 -> 使用 HTTP 或者使用 HTTPS 协议发送请求 ->对于访问静态资源的 HTTP 请求:CDN -> CDN 回源到对象存储 OSS 或者源服务器对于访问动态资源的 HTTP 请求:负载均衡器 Nginx -> 应用服务器【API 网关(Zuul、GateWay 等)-> 内部服务(微服务,Controller、Service、DAO)】-> 缓存系统(Redis、EhCache 等) -> 存储系统(MySQL、PostgreSQL、MongoDB 等)为了减少响应时间,整个过程中的几乎每一个环节都会有缓存。为了提高系统的可用性、性能,整个过程中的很多环节都需要部署多节点。浏览器当用户在浏览器的地址栏中输入 URL 并点击回车后,首先由浏览器进行处理。浏览器缓存当用户在浏览器的地址栏中输入 URL 并点击回车后,浏览器会查看自己是否缓存了该资源。如果浏览器缓存了该资源,并且缓存未过期,那么直接从缓存中获取资源,不向服务端发送请求(命中强缓存)。如果浏览器缓存了该资源,但是缓存已经过期了,那么浏览器向服务端发送条件请求。服务端会根据条件请求首部字段(If-Match、If-Modified-Since 等)来判断是否命中协商缓存。如果命中了协商缓存,那么服务端会返回 304 状态码(Not Modified),而不返回浏览器请求的资源。告诉浏览器可以直接用浏览器缓存中的资源。如果没有命中协商缓存,那么服务器返回浏览器请求的资源。DNS 域名解析当用户在浏览器的地址栏中输入 URL 并点击回车后,浏览器要判断 URL 中的是 IP 地址,还是域名。如果 URL 中的是域名,那么首先要做的就是域名解析。域名解析的过程:首先是浏览器查看浏览器的缓存。如果浏览器中没有该域名的缓存,那么浏览器询问【本地 DNS 解析器】,【本地 DNS 解析器】首先查看本地 DNS 缓存。如果本地 DNS 缓存中没有该域名的缓存,那么【本地 DNS 解析器】请求【本地 DNS 服务器】进行域名解析。如果【本地 DNS 服务器】中没有该域名的缓存,那么【本地 DNS 服务器】向 DNS 系统中的其他远程 DNS 服务器发送查询请求。如果域名解析失败,浏览器会展示一个报错页面,提示域名不存在。如果域名解析成功,浏览器就获取到一个域名对应的 IP 地址。以【本地 DNS 解析器】请求【本地 DNS 服务器】进行 www.CDNbook.com 域名的解析为例:(1)【本地 DNS 解析器】向【本地 DNS 服务器】发送域名解析请求。(8)【本地 DNS 解析器】收到来自【本地 DNS 服务器】的应答。(2)【本地 DNS 服务器】向【根 DNS 服务器】发送域名解析请求,【根 DNS 服务器】返回 .com 顶级域的域名服务器列表(多条 NS 记录)。(4)【本地 DNS 服务器】收到应答后,在 .com 顶级域的域名服务器列表中选择一个 IP 地址,向这个 IP 地址对应的 DNS 服务器发送域名解析请求,.com 顶级域的域名服务器返回 CDNbook.com 域的域名服务器列表。(6)【本地 DNS 服务器】收到应答后,在 CDNbook.com 域的域名服务器列表中选择一个 IP 地址,向这个 IP 地址对应的 DNS 服务器发送域名解析请求,CDNbook.com 域的域名服务器返回 www.CDNbook.com 域的 A 记录列表(多个 IP 地址)。(8)【本地 DNS 服务器】收到应答后,在 www.CDNbook.com 域的 A 记录列表中选择一个 IP 地址,将该 IP 地址返回给【本地 DNS 解析器】。(9)浏览器获取到域名对应的 IP 地址,这个 IP 地址可能是 CDN 服务器的 IP 地址,也可能是源服务器的 IP 地址。无论是什么服务器的 IP 地址,浏览器都会向这个服务器发送请求,服务器将用户请求的内容返回给浏览器。URL 编码URL 编码也被称为百分号编码。URL 编码的作用是:在 URL 中,使用 “安全的字符”(允许出现的字符、无歧义的字符) 替换 “不安全的字符”(不允许出现的字符、有歧义的字符)将 “非 ASCII 字符” 编码为 “ASCII 字符”,便于在 URL 中传输非 ASCII 字符。(URL 中只能出现 ASCII 字符,不能出现非 ASCII 字符)将 “空格” 编码为 “%20”,便于在 URL 中传输空格。(URL 中不能出现空格)将 “没有表示特殊含义的保留字符” 进行 URL 编码。(URL 中多个查询参数之间用 & 符号分隔。如果参数值中包含了 & 字符,那么会对 URL 解析造成影响,因此需要对造成歧义的 & 符号进行编码)URL 编码的规则:简单来说,如果需要对一个字符进行 URL 编码,首先需要判断该字符是否是 ASCII 字符:如果一个字符是 ASCII 字符,那么对该字符进行 URL 编码,首先需要把该字符的 ASCII 的值表示为两个 16 进制的数字,然后在其前面放置转义字符 %,就得到了该字符的 URL 编码结果。如果一个字符是非 ASCII 字符,那么对该字符进行 URL 编码,首先需要使用指定的字符编码方式(建议使用 UTF-8 字符编码),将 “非 ASCII 字符” 编码为字节序列(字节序列即二进制数据);然后对其字节序列进行 URL 编码。URL 编码 “二进制数据”,首先需要把 “二进制数据” 表示为 8 位组的序列,将每个 8 位组表示为两个 16 进制的数字,然后在其前面放置转义字符 %,就得到了 “二进制数据” 的 URL 编码结果。HTTPS 通信浏览器使用 HTTP 或者使用 HTTPS 协议和服务器通信。使用 HTTPS 协议通信,通信的双方会先建立 TCP 连接,然后执行 TLS 握手,之后就可以在安全的通信环境里发送 HTTP 报文了。TCP 握手一开始,客户端和服务端都处于 close 状态。先是服务端监听某个端口,此时服务端处于 listen 状态。这个时候客户端就可以发送连接请求报文了。第一次握手客户端会主动发送连接请求报文,随机初始化序列号为 x,并把 SYN 标志位设置为 1,表示 SYN 报文。客户端发送 SYN 报文后,客户端进入 syn_sent 状态。第二次握手服务端收到 SYN 报文后,服务端会发送 SYN-ACK 报文,用于对客户端发送的 SYN 报文进行应答。服务端会随机初始化序列号为 y,确认序列号设置为 x + 1,并把 SYN 标志位、ACK 标志位设置为 1。服务端发送 SYN-ACK 报文后,服务端进入 syn_receive 状态。第三次握手客户端收到 SYN-ACK 报文后,客户端会发送 ACK 报文,用于对服务端发送的报文进行应答。客户端会将序列号设置为 x + 1,确认序列号设置为 y + 1,ACK 标志位设置为 1。客户端发送 ACK 报文后,客户端处于 established 状态。当服务端收到 ACK 报文后,服务端进入 established 状态。此时 TCP 连接就建立完成了,客户端和服务端就可以相互发送数据了。TLS 握手TLS 握手过程的简要描述:通信的双方在 TLS 握手的过程中协商 TLS 的版本号、密码套件,交换随机数、数字证书和密钥参数,最终通信的双方协商得到会话密钥。("Hello" 消息交换随机数,"Key Exchange" 消息交换 "Pre Master Secret")浏览器给服务器发送 "Client Hello" 消息,服务器给浏览器发送 "Server Hello" 消息。通信的双方协商 TLS 的版本号、密码套件,并交换随机数。交换数字证书:服务器为了向浏览器证明自己的身份,服务器给浏览器发送 "server Certificate" 消息,以发送数字证书链,其中包含了两个证书。一个是 CA 机构颁发的数字证书,另一个是 CA 机构的数字证书。服务器给浏览器发送 "Server Key Exchange" 消息,浏览器给服务器发送 "Client Key Exchange" 消息。通信的双方交换密钥参数(Client Params 和 Server Params),然后通信的双方就用 ECDHE 算法算出 "Pre Master Secret"。通信的双方根据自己已知的参数(Client Random、Server Random 和 Pre Master Secret)算出主密钥 "Master Secret",并使用主密钥拓展出更多的密钥(会话密钥),避免只用一个密钥带来的安全隐患。浏览器给服务器发送 "Change Cipher Spec" 消息,服务器也给浏览器发送 "Change Cipher Spec" 消息。告诉对方:后续传输的都是对称密钥加密的密文。浏览器给服务器发送 "Finished" 消息,服务器也给浏览器发送 "Finished" 消息。通信的双方把之前发送的数据做个摘要,再用对称密钥加密一下,让对方做个验证。双方都验证成功,握手正式结束,之后就可以正常收发被加密的 HTTP 请求和响应了。TCP / IP 模型的通信发送数据包当用户在浏览器的地址栏中输入 URL 并点击回车后,首先由浏览器进行处理,这些处理相当于应用层功能。浏览器处理完成以后,浏览器依据 HTTP 规范构建报文,并将 HTTP 报文发送给传输层的 TCP。TCP 模块的处理:TCP 根据浏览器的指示,负责建立连接、发送数据以及断开连接。TCP 在浏览器发送过来的数据前端再加上自己的 TCP 首部,随后将附加了 TCP 首部的包再发送给网络层的 IP。IP 模块的处理:IP 将 TCP 传过来的 TCP 首部和 TCP 数据合起来当做自己的数据,并在 TCP 首部的前端再加上自己的 IP 首部。IP 包生成后,参考路由控制表决定接受此 IP 包的路由器 或 主机。随后,IP 包将被发送给连接这些路由器 或 主机网络接口的驱动程序,以实现真正的发送数据。如果尚不知道接收端的物理地址(MAC 地址),可以利用 ARP(Address Resolution Protocol) 通过 IP 地址查找物理地址(MAC地址)。只要知道了对端的物理地址(MAC 地址),就可以将 MAC 地址和 IP 地址交给以太网的驱动程序,以实现真正的发送数据。网络接口(以太网驱动) 的处理:以太网的驱动程序将 IP 传过来的 IP 首部和 IP 数据合起来当做自己的数据,并在 IP 首部的前端再加上自己的以太网首部。以太网数据包将通过物理层传输给接收端。接收数据包网络接口(以太网驱动) 的处理:主机收到以太网包以后,首先从以太网的包首部找到物理地址(MAC 地址)。主机根据物理地址(MAC 地址),判断是否为发给自己的包。如果接收到的不是发给自己的包,那么主机丢弃数据。如果接收到的恰好是发给自己的包,那么主机接收数据并查找以太网包首部中的类型域(类型域记录上一层的协议类型),从而确定将数据传给哪个处理程序。如果上一层协议是 IP,那么就将数据传给处理 IP 的程序;如果上一层协议是 ARP,那么就将数据传给处理 ARP 的程序;如果以太网包首部的类型域包含了一个无法识别的协议类型,那么主机丢弃数据。IP 模块的处理:IP 模块收到 IP 包首部及后面的数据部分以后,也做和以太网驱动类似的处理。如果判断得出包首部中的 IP 地址与自己的 IP 地址匹配,那么就接收数据并查找 IP 包首部中的类型域(类型域记录上一层的协议类型),从而确定将数据传给哪个处理程序。如果上一层协议是 TCP,那么就将数据传给处理 TCP 的程序;如果上一层协议是 UDP,那么就将数据传给处理 UDP 的程序。对于有路由器的情况下,接收端的 IP 地址往往不是自己的 IP 地址,此时需要借助路由控制表,在查找到应该送达的主机 或 路由器以后再转发数据。TCP 模块的处理:TCP 模块收到 TCP 包首部及后面的数据部分以后,首先会计算一下校验和,判断数据是否被破坏。然后检查是否在按照序号接收数据。最后检查端口号,从而确定将数据传给哪个具体的应用程序。TCP 模块接收数据完毕后,接收端给发送端发送一个 “确认(ACK)”。如果发送端没有收到这个确认信息,那么发送端会认为接收端没有接收到数据,然后发送端会一直反复发送。数据被完整地接收以后,会传给由端口号识别的应用程序。应用程序的处理:接收端应用程序会直接接收发送端发送的数据。服务器准备好发送端应用程序所需的数据以后,以同样的方式将数据发送到发送端应用程序。服务端当浏览器的请求到达服务端之后,请求首先经过 Linux 虚拟服务器 LVS,调度器将网络请求分发到 Nginx 上。如果 Nginx 上缓存的有用户请求的内容,那么 Nginx 直接将用户请求的内容发送给浏览器。如果 Nginx 上没有缓存用户请求的内容,那么 Nginx 访问应用服务器(Web 服务器,比如 Java 的 Tomcat / Netty / Jetty,Python 的 Django)获取资源,再将用户请求的内容返回给浏览器。Nginx 会根据缓存策略缓存从应用服务器获取到的资源,浏览器也会根据缓存策略缓存收到的内容。Nginx 也可以直接访问缓存系统尝试获取资源(Varnish 缓存静态资源,Redis 缓存动态资源)。如果缓存系统中没有用户请求的内容,再访问应用服务器获取资源。当 Nginx 的请求到达应用服务器之后,请求首先经过 API 网关。API 网关根据路由规则,将外部访问网关地址的流量路由到内部服务集群中正确的服务节点上。网关还可以根据需要作为流量过滤器来使用,提供某些额外的可选的功能,例如:流量治理:流量控制、服务容错(限流、降级、熔断)安全防护、访问控制:防刷控制、防爬虫、黑白名单、认证、授权、数据完整性校验、数据加密监控:性能监控、日志监控其他:协议适配转换、缓存外部访问网关地址的流量被路由到内部服务集群中正确的服务节点上之后,服务节点会再访问缓存系统(比如 Redis、EhCache 等),存储系统(比如 MySQL、PostgreSQL、MongoDB 等)实现业务操作,获取资源。服务节点将获取到的资源返回给 API 网关,API 网关将资源返回给 Nginx,Nginx 再将用户请求的内容返回给客户端,客户端依据 HTTP 规则解析报文,并将用户请求的内容显示在页面上。参考资料08 | 键入网址再按下回车,后面究竟发生了什么? (geekbang.org)域名 - 真正的飞鱼 - 博客园 (cnblogs.com)URL编码 - 真正的飞鱼 - 博客园 (cnblogs.com)计算机网络 | TCP 连接的建立 和 TCP 连接的断开 - 真正的飞鱼 - 博客园 (cnblogs.com)《图解TCP / IP》
「 前端开发规范 」10人小团队前端开发规范参考这篇就够了!
前言引自《阿里规约》的开头片段:----现代软件架构的复杂性需要协同开发完成,如何高效地协同呢?无规矩不成方圆,无规范难以协同,比如,制订交通法规表面上是要限制行车权,实际上是保障公众的人身安全,试想如果没有限速,没有红绿灯,谁还敢上路行驶。对软件来说,适当的规范和标准绝不是消灭代码内容的创造性、优雅性,而是限制过度个性化,以一种普遍认可的统一方式一起做事,提升协作效率,降低沟通成本。代码的字里行间流淌的是软件系统的血液,质量的提升是尽可能少踩坑,杜绝踩重复的坑,切实提升系统稳定性,码出质量。一、编程规约(一)命名规范1.1.1 项目命名全部采用小写方式, 以中划线分隔。正例:mall-management-system反例:mall_management-system / mallManagementSystem1.1.2 目录命名全部采用小写方式, 以中划线分隔,有复数结构时,要采用复数命名法, 缩写不用复数正例: scripts / styles / components / images / utils / layouts / demo-styles / demo-scripts / img / doc反例: script / style / demo_scripts / demoStyles / imgs / docs【特殊】VUE 的项目中的 components 中的组件目录,使用 kebab-case 命名正例: head-search / page-loading / authorized / notice-icon反例: HeadSearch / PageLoading【特殊】VUE 的项目中的除 components 组件目录外的所有目录也使用 kebab-case 命名正例: page-one / shopping-car / user-management反例: ShoppingCar / UserManagement1.1.3 JS、CSS、SCSS、HTML、PNG 文件命名全部采用小写方式, 以中划线分隔正例: render-dom.js / signup.css / index.html / company-logo.png反例: renderDom.js / UserManagement.html1.1.4 命名严谨性代码中的命名严禁使用拼音与英文混合的方式,更不允许直接使用中文的方式。 说明:正确的英文拼写和语法可以让阅读者易于理解,避免歧义。注意,即使纯拼音命名方式也要避免采用正例:henan / luoyang / rmb 等国际通用的名称,可视同英文。反例:DaZhePromotion [打折] / getPingfenByName() [评分] / int 某变量 = 3杜绝完全不规范的缩写,避免望文不知义:反例:AbstractClass“缩写”命名成 AbsClass;condition“缩写”命名成 condi,此类随意缩写严重降低了代码的可阅读性。(二)HTML 规范 (Vue Template 同样适用)1.2.1 HTML 类型推荐使用 HTML5 的文档类型申明: <!DOCTYPE html>.(建议使用 text/html 格式的 HTML。避免使用 XHTML。XHTML 以及它的属性,比如 application/xhtml+xml 在浏览器中的应用支持与优化空间都十分有限)。规定字符编码
IE 兼容模式
规定字符编码
doctype 大写正例:<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta charset="UTF-8" />
<title>Page title</title>
</head>
<body>
<img src="images/company-logo.png" alt="Company" />
</body>
</html>1.2.2 缩进缩进使用 2 个空格(一个 tab)嵌套的节点应该缩进。1.2.3 分块注释在每一个块状元素,列表元素和表格元素后,加上一对 HTML 注释。注释格式<!-- 英文 中文 start >
<!-- 英文 中文 end >正例:
<body>
<!-- header 头部 start -->
<header>
<div class="container">
<a href="#">
<!-- 图片会把a标签给撑开,所以不用设置a标签的大小 -->
<img src="images/header.jpg" />
</a>
</div>
</header>
<!-- header 头部 end -->
</body>1.2.4 语义化标签HTML5 中新增很多语义化标签,所以优先使用语义化标签,避免一个页面都是 div 或者 p 标签正例
<header></header>
<footer></footer>
反例
<div>
<p></p>
</div>1.2.5 引号使用双引号(" ") 而不是单引号(' ') 。正例: <div class="news-div"></div>反例: <div class='news-div'></div>(三) CSS 规范1.3.1 命名类名使用小写字母,以中划线分隔
id 采用驼峰式命名
scss 中的变量、函数、混合、placeholder 采用驼峰式命名ID 和 class 的名称总是使用可以反应元素目的和用途的名称,或其他通用的名称,代替表象和晦涩难懂的名称不推荐:
.fw-800 {
font-weight: 800;
}
.red {
color: red;
}
推荐:
.heavy {
font-weight: 800;
}
.important {
color: red;
}1.3.2 选择器1)css 选择器中避免使用标签名从结构、表现、行为分离的原则来看,应该尽量避免 css 中出现 HTML 标签,并且在 css 选择器中出现标签名会存在潜在的问题。2)很多前端开发人员写选择器链的时候不使用 直接子选择器(注:直接子选择器和后代选择器的区别)。有时,这可能会导致疼痛的设计问题并且有时候可能会很耗性能。然而,在任何情况下,这是一个非常不好的做法。如果你不写很通用的,需要匹配到 DOM 末端的选择器, 你应该总是考虑直接子选择器。不推荐:
.content .title {
font-size: 2rem;
}
推荐:
.content > .title {
font-size: 2rem;
}1.3.3 尽量使用缩写属性不推荐:
border-top-style: none;
font-family: palatino, georgia, serif;
font-size: 100%;
line-height: 1.6;
padding-bottom: 2em;
padding-left: 1em;
padding-right: 1em;
padding-top: 0;
推荐:
border-top: 0;
font: 100%/1.6 palatino, georgia, serif;
padding: 0 1em 2em;1.3.4 每个选择器及属性独占一行不推荐:
button{
width:100px;height:50px;color:#fff;background:#00a0e9;
}
推荐:
button{
width:100px;
height:50px;
color:#fff;
background:#00a0e9;
}1.3.5 省略0后面的单位不推荐:
div{
padding-bottom: 0px;
margin: 0em;
}
推荐:
div{
padding-bottom: 0;
margin: 0;
}1.3.6 避免使用ID选择器及全局标签选择器防止污染全局样式不推荐:
#header{
padding-bottom: 0px;
margin: 0em;
}
推荐:
.header{
padding-bottom: 0px;
margin: 0em;
}(四) LESS 规范1.4.1 代码组织1)将公共less文件放置在style/less/common文件夹例:// color.less,common.less2)按以下顺序组织1、@import;2、变量声明;3、样式声明;@import "mixins/size.less";
@default-text-color: #333;
.page {
width: 960px;
margin: 0 auto;
}1.4.2 避免嵌套层级过多 将嵌套深度限制在3级。对于超过4级的嵌套,给予重新评估。这可以避免出现过于详实的CSS选择器。避免大量的嵌套规则。当可读性受到影响时,将之打断。推荐避免出现多于20行的嵌套规则出现不推荐:
.main{
.title{
.name{
color:#fff
}
}
}
推荐:
.main-title{
.name{
color:#fff
}
}(五) Javascript 规范1.5.1 命名1) 采用小写驼峰命名 lowerCamelCase,代码中的命名均不能以下划线,也不能以下划线或美元符号结束反例: _name / name_ / name$2) 方法名、参数名、成员变量、局部变量都统一使用 lowerCamelCase 风格,必须遵从驼峰形式。正例: localValue / getHttpMessage() / inputUserId**其中 method 方法命名必须是 动词 或者 动词+名词 形式**正例:saveShopCarData /openShopCarInfoDialog反例:save / open / show / go**特此说明,增删查改,详情统一使用如下 5 个单词,不得使用其他(目的是为了统一各个端)**add / update / delete / detail / get附: 函数方法常用的动词:get 获取/set 设置,
add 增加/remove 删除
create 创建/destory 移除
start 启动/stop 停止
open 打开/close 关闭,
read 读取/write 写入
load 载入/save 保存,
create 创建/destroy 销毁
begin 开始/end 结束,
backup 备份/restore 恢复
import 导入/export 导出,
split 分割/merge 合并
inject 注入/extract 提取,
attach 附着/detach 脱离
bind 绑定/separate 分离,
view 查看/browse 浏览
edit 编辑/modify 修改,
select 选取/mark 标记
copy 复制/paste 粘贴,
undo 撤销/redo 重做
insert 插入/delete 移除,
add 加入/append 添加
clean 清理/clear 清除,
index 索引/sort 排序
find 查找/search 搜索,
increase 增加/decrease 减少
play 播放/pause 暂停,
launch 启动/run 运行
compile 编译/execute 执行,
debug 调试/trace 跟踪
observe 观察/listen 监听,
build 构建/publish 发布
input 输入/output 输出,
encode 编码/decode 解码
encrypt 加密/decrypt 解密,
compress 压缩/decompress 解压缩
pack 打包/unpack 解包,
parse 解析/emit 生成
connect 连接/disconnect 断开,
send 发送/receive 接收
download 下载/upload 上传,
refresh 刷新/synchronize 同步
update 更新/revert 复原,
lock 锁定/unlock 解锁
check out 签出/check in 签入,
submit 提交/commit 交付
push 推/pull 拉,
expand 展开/collapse 折叠
begin 起始/end 结束,
start 开始/finish 完成
enter 进入/exit 退出,
abort 放弃/quit 离开
obsolete 废弃/depreciate 废旧,
collect 收集/aggregate 聚集3) 常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要嫌名字长。正例: MAX_STOCK_COUNT反例: MAX_COUNT1.5.2 代码格式1) 使用 2 个空格进行缩进正例:if (x < y) {
x += 10;
} else {
x += 1;
}2) 不同逻辑、不同语义、不同业务的代码之间插入一个空行分隔开来以提升可读性。说明:任何情形,没有必要插入多个空行进行隔开。1.5.3 字符串统一使用单引号(‘),不使用双引号(“)。这在创建 HTML 字符串非常有好处:正例:
let str = 'foo';
let testDiv = '<div id="test"></div>';
反例:
let str = 'foo';
let testDiv = "<div id='test'></div>";1.5.4 对象声明1)使用字面值创建对象正例: let user = {};反例: let user = new Object();2) 使用字面量来代替对象构造器正例:
var user = {
age: 0,
name: 1,
city: 3
};
反例:
var user = new Object();
user.age = 0;
user.name = 0;
user.city = 0;1.5.5 使用 ES6,7必须优先使用 ES6,7 中新增的语法糖和函数。这将简化你的程序,并让你的代码更加灵活和可复用。必须强制使用 ES6, ES7 的新语法,比如箭头函数、await/async , 解构, let , for...of 等等1.5.6 括号下列关键字后必须有大括号(即使代码块的内容只有一行):if, else, for, while, do, switch, try, catch, finally, with。正例:
if (condition) {
doSomething();
}
反例:
if (condition) doSomething();1.5.7 undefined 判断永远不要直接使用 undefined 进行变量判断;使用 typeof 和字符串'undefined'对变量进行判断。正例:
if (typeof person === 'undefined') {
...
}
反例:
if (person === undefined) {
...
}1.5.8 条件判断和循环最多三层条件判断能使用三目运算符和逻辑运算符解决的,就不要使用条件判断,但是谨记不要写太长的三目运算符。如果超过 3 层请抽成函数,并写清楚注释。1.5.9 this 的转换命名对上下文 this 的引用只能使用'self'来命名1.5.10 慎用 console.log因 console.log 大量使用会有性能问题,所以在非 webpack 项目中谨慎使用 log 功能二、Vue 项目规范(一) Vue 编码基础vue 项目规范以 Vue 官方规范 (https://cn.vuejs.org/v2/style-guide/) 中的 A 规范为基础,在其上面进行项目开发,故所有代码均遵守该规范。请仔仔细细阅读 Vue 官方规范,切记,此为第一步。2.1.1. 组件规范1) 组件名为多个单词。组件名应该始终是多个单词组成(大于等于 2),且命名规范为KebabCase格式。这样做可以避免跟现有的以及未来的 HTML 元素相冲突,因为所有的 HTML 元素名称都是单个单词的。正例:export default {
name: 'TodoItem'
// ...
};反例:export default {
name: 'Todo',
// ...
}
export default {
name: 'todo-item',
// ...
}2) 组件文件名为 pascal-case 格式正例:components/
|- my-component.vue反例:components/
|- myComponent.vue
|- MyComponent.vue3) 基础组件文件名为 base 开头,使用完整单词而不是缩写。正例:components/
|- base-button.vue
|- base-table.vue
|- base-icon.vue反例:components/
|- MyButton.vue
|- VueTable.vue
|- Icon.vue4) 和父组件紧密耦合的子组件应该以父组件名作为前缀命名正例:components/
|- todo-list.vue
|- todo-list-item.vue
|- todo-list-item-button.vue
|- user-profile-options.vue (完整单词)反例:components/
|- TodoList.vue
|- TodoItem.vue
|- TodoButton.vue
|- UProfOpts.vue (使用了缩写)5) 在 Template 模版中使用组件,应使用 PascalCase 模式,并且使用自闭合组件。正例:<!-- 在单文件组件、字符串模板和 JSX 中 -->
<MyComponent />
<Row><table :column="data"/></Row>反例:<my-component /> <row><table :column="data"/></row>6) 组件的 data 必须是一个函数当在组件中使用 data 属性的时候 (除了 new Vue 外的任何地方),它的值必须是返回一个对象的函数。 因为如果直接是一个对象的话,子组件之间的属性值会互相影响。正例:export default {
data () {
• return {
• name: 'jack'
• }
}
}反例:export default {
data: {
• name: 'jack'
}
}7) Prop 定义应该尽量详细必须使用 camelCase 驼峰命名
必须指定类型
必须加上注释,表明其含义
必须加上 required 或者 default,两者二选其一
如果有业务需要,必须加上 validator 验证正例:props: {
// 组件状态,用于控制组件的颜色
status: {
• type: String,
required: true,
• validator: function (value) {
• return [
• 'succ',
• 'info',
• 'error'
• ].indexOf(value) !== -1
• }
},
// 用户级别,用于显示皇冠个数
userLevel:{
type: String,
required: true
}
}8) 为组件样式设置作用域正例:<template>
<button class="btn btn-close">X</button>
</template>
<!-- 使用 `scoped` 特性 -->
<style scoped>
.btn-close {
background-color: red;
}
</style>反例:<template>
<button class="btn btn-close">X</button>
</template>
<!-- 没有使用 `scoped` 特性 -->
<style>
.btn-close {
background-color: red;
}
</style>9) 如果特性元素较多,应该主动换行。正例:<MyComponent foo="a" bar="b" baz="c"
foo="a" bar="b" baz="c"
foo="a" bar="b" baz="c"
/>反例:<MyComponent foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c" foo="a" bar="b" baz="c"/>2.1.2. 模板中使用简单的表达式组件模板应该只包含简单的表达式,复杂的表达式则应该重构为计算属性或方法。复杂表达式会让你的模板变得不那么声明式。我们应该尽量描述应该出现的是什么,而非如何计算那个值。而且计算属性和方法使得代码可以重用。正例:<template>
<p>{{ normalizedFullName }}</p>
</template>
// 复杂表达式已经移入一个计算属性
computed: {
normalizedFullName: function () {
• return this.fullName.split(' ').map(function (word) {
• return word[0].toUpperCase() + word.slice(1)
• }).join(' ')
}
}反例:<template>
<p>
{{
fullName.split(' ').map(function (word) {
• return word[0].toUpperCase() + word.slice(1)
}).join(' ')
}}
</p>
</template>2.1.3 指令都使用缩写形式指令推荐都使用缩写形式,(用 : 表示 v-bind: 、用 @ 表示 v-on: 和用 # 表示 v-slot:)正例:<input
@input="onInput"
@focus="onFocus"
>反例:<input
v-on:input="onInput"
@focus="onFocus"
> 2.1.4 标签顺序保持一致单文件组件应该总是让标签顺序保持为 <template> 、<script>、 <style>正例:<template>...</template>
<script>...</script>
<style>...</style>反例:<template>...</template>
<style>...</style>
<script>...</script>2.1.5 必须为 v-for 设置键值 key2.1.6 v-show 与 v-if 选择如果运行时,需要非常频繁地切换,使用 v-show ;如果在运行时,条件很少改变,使用 v-if。2.1.7 script 标签内部结构顺序components > props > data > computed > watch > filter > 钩子函数(钩子函数按其执行顺序) > methods2.1.8 Vue Router 规范1) 页面跳转数据传递使用路由参数页面跳转,例如 A 页面跳转到 B 页面,需要将 A 页面的数据传递到 B 页面,推荐使用 路由参数进行传参,而不是将需要传递的数据保存 vuex,然后在 B 页面取出 vuex 的数据,因为如果在 B 页面刷新会导致 vuex 数据丢失,导致 B 页面无法正常显示数据。正例:let id = ' 123';
this.$router.push({ name: 'userCenter', query: { id: id } });2) 使用路由懒加载(延迟加载)机制{
path: '/uploadAttachment',
name: 'uploadAttachment',
meta: {
title: '上传附件'
},
component: () => import('@/view/components/uploadAttachment/index.vue')
},3) router 中的命名规范path、childrenPoints 命名规范采用kebab-case命名规范(尽量vue文件的目录结构保持一致,因为目录、文件名都是kebab-case,这样很方便找到对应的文件)name 命名规范采用KebabCase命名规范且和component组件名保持一致!(因为要保持keep-alive特性,keep-alive按照component的name进行缓存,所以两者必须高度保持一致) // 动态加载
export const reload = [
{
path: '/reload',
name: 'reload',
component: Main,
meta: {
title: '动态加载',
icon: 'icon iconfont'
},
children: [
{
path: '/reload/smart-reload-list',
name: 'SmartReloadList',
meta: {
title: 'SmartReload',
childrenPoints: [
{
title: '查询',
name: 'smart-reload-search'
},
{
title: '执行reload',
name: 'smart-reload-update'
},
{
title: '查看执行结果',
name: 'smart-reload-result'
}
]
},
component: () =>
import('@/views/reload/smart-reload/smart-reload-list.vue')
}
]
}
];4) router 中的 path 命名规范path除了采用kebab-case命名规范以外,必须以 / 开头,即使是children里的path也要以 / 开头。如下示例*目的:经常有这样的场景:某个页面有问题,要立刻找到这个vue文件,如果不用以/开头,path为parent和children组成的,可能经常需要在router文件里搜索多次才能找到,而如果以/开头,则能立刻搜索到对应的组件* {
path: '/file',
name: 'File',
component: Main,
meta: {
title: '文件服务',
icon: 'ios-cloud-upload'
},
children: [
{
path: '/file/file-list',
name: 'FileList',
component: () => import('@/views/file/file-list.vue')
},
{
path: '/file/file-add',
name: 'FileAdd',
component: () => import('@/views/file/file-add.vue')
},
{
path: '/file/file-update',
name: 'FileUpdate',
component: () => import('@/views/file/file-update.vue')
}
]
}(二) Vue 项目目录规范2.2.1 基础vue 项目中的所有命名一定要与后端命名统一。比如权限:后端 privilege, 前端无论 router , store, api 等都必须使用 privielege 单词!2.2.2 使用 Vue-cli 脚手架使用 vue-cli3 来初始化项目,项目名按照上面的命名规范。2.2.3 目录说明目录名按照上面的命名规范,其中 components 组件用大写驼峰,其余除 components 组件目录外的所有目录均使用 kebab-case 命名。 src 源码目录 |-- api 所有api接口 |-- assets 静态资源,images, icons, styles等 |-- components 公用组件 |-- config 配置信息 |-- constants 常量信息,项目所有Enum, 全局常量等 |-- directives 自定义指令 |-- filters 过滤器,全局工具 |-- datas 模拟数据,临时存放 |-- lib 外部引用的插件存放及修改文件 |-- mock 模拟接口,临时存放 |-- plugins 插件,全局使用 |-- router 路由,统一管理 |-- store vuex, 统一管理 |-- themes 自定义样式主题 |-- views 视图目录 | |-- role role模块名 | |-- |-- role-list.vue role列表页面 | |-- |-- role-add.vue role新建页面 | |-- |-- role-update.vue role更新页面 | |-- |-- index.less role模块样式 | |-- |-- components role模块通用组件文件夹 | |-- employee employee模块1) api 目录文件、变量命名要与后端保持一致。
此目录对应后端 API 接口,按照后端一个 controller 一个 api js 文件。若项目较大时,可以按照业务划分子目录,并与后端保持一致。
api 中的方法名字要与后端 api url 尽量保持语义高度一致性。
对于 api 中的每个方法要添加注释,注释与后端 swagger 文档保持一致。正例:后端 url: EmployeeController.java/employee/add
/employee/delete/{id}
/employee/update前端: employee.js// 添加员工
addEmployee: (data) => {
return postAxios('/employee/add', data)
},
// 更新员工信息
updateEmployee: (data) => {
return postAxios('/employee/update', data)
},
// 删除员工
deleteEmployee: (employeeId) => {
return postAxios('/employee/delete/' + employeeId)
},2) assets 目录assets 为静态资源,里面存放 images, styles, icons 等静态资源,静态资源命名格式为 kebab-case |assets |-- icons |-- images | |-- background-color.png | |-- upload-header.png |-- styles3) components 目录此目录应按照组件进行目录划分,目录命名为 KebabCase,组件命名规则也为 KebabCase |components |-- error-log | |-- index.vue | |-- index.less |-- markdown-editor | |-- index.vue | |-- index.js |-- kebab-case 4) constants 目录此目录存放项目所有常量,如果常量在 vue 中使用,请使用 vue-enum 插件(https://www.npmjs.com/package/vue-enum)目录结构: |constants |-- index.js |-- role.js |-- employee.js例子: employee.js export const EMPLOYEE_STATUS = {
NORMAL: {
value: 1,
desc: '正常'
},
DISABLED: {
value: 1,
desc: '禁用'
},
DELETED: {
value: 2,
desc: '已删除'
}
};
export const EMPLOYEE_ACCOUNT_TYPE = {
QQ: {
value: 1,
desc: 'QQ登录'
},
WECHAT: {
value: 2,
desc: '微信登录'
},
DINGDING: {
value: 3,
desc: '钉钉登录'
},
USERNAME: {
value: 4,
desc: '用户名密码登录'
}
};
export default {
EMPLOYEE_STATUS,
EMPLOYEE_ACCOUNT_TYPE
};5) router 与 store 目录这两个目录一定要将业务进行拆分,不能放到一个 js 文件里。router 尽量按照 views 中的结构保持一致store 按照业务进行拆分不同的 js 文件6) views 目录命名要与后端、router、api 等保持一致components 中组件要使用 PascalCase 规则 |-- views 视图目录 | |-- role role模块名 | | |-- role-list.vue role列表页面 | | |-- role-add.vue role新建页面 | | |-- role-update.vue role更新页面 | | |-- index.less role模块样式 | | |-- components role模块通用组件文件夹 | | | |-- role-header.vue role头部组件 | | | |-- role-modal.vue role弹出框组件 | |-- employee employee模块 | |-- behavior-log 行为日志log模块 | |-- code-generator 代码生成器模块2.2.4 注释说明整理必须加注释的地方公共组件使用说明
api 目录的接口 js 文件必须加注释
store 中的 state, mutation, action 等必须加注释
vue 文件中的 template 必须加注释,若文件较大添加 start end 注释
vue 文件的 methods,每个 method 必须添加注释
vue 文件的 data, 非常见单词要加注释2.2.5 其他1) 尽量不要手动操作 DOM因使用 vue 框架,所以在项目开发中尽量使用 vue 的数据驱动更新 DOM,尽量(不到万不得已)不要手动操作 DOM,包括:增删改 dom 元素、以及更改样式、添加事件等。2) 删除无用代码因使用了 git/svn 等代码版本工具,对于无用代码必须及时删除,例如:一些调试的 console 语句、无用的弃用功能代码。最后规范的目的是为了编写高质量的代码,让你的团队成员每天得心情都是愉悦的,大家在一起是快乐的。参考:本篇内容参考自开源社区,感谢前人的经验和付出,让我们可以有机会站在巨人的肩膀上眺望星辰大海。
通用缓存存储设计实践
目录介绍01.整体概述说明1.1 项目背景介绍1.2 遇到问题记录1.3 基础概念介绍1.4 设计目标1.5 产生收益分析02.市面存储方案2.1 缓存存储有哪些2.2 缓存策略有哪些2.3 常见存储方案2.4 市面存储方案说明2.5 存储方案的不足03.存储方案原理3.1 Sp存储原理分析3.2 MMKV存储原理分析3.3 LruCache考量分析3.4 DiskLru原理分析3.5 DataStore分析3.6 HashMap存储分析3.7 Sqlite存储分析3.8 使用存储的注意点3.9 各种数据存储文件04.通用缓存方案思路4.1 如何兼容不同缓存4.2 打造通用缓存Api4.3 切换不同缓存方式4.4 缓存的过期处理4.5 缓存的阀值处理4.6 缓存的线程安全性4.7 缓存数据的迁移4.8 缓存数据加密处理4.9 缓存效率的对比05.方案基础设计5.1 整体架构图5.2 UML设计图5.3 关键流程图5.4 模块间依赖关系06.其他设计说明6.1 性能设计说明6.2 稳定性设计6.3 灰度设计6.4 降级设计6.5 异常设计说明6.6 兼容性设计6.7 自测性设计07.通用Api设计7.1 如何依赖该库7.2 初始化缓存库7.3 切换各种缓存方案7.4 数据的存和取7.5 线程安全考量7.6 查看缓存文件数据7.7 如何选择合适方案08.其他说明介绍8.1 遇到的坑分析8.2 遗留的问题8.3 未来的规划8.4 参考链接记录01.整体概述说明1.1 项目背景介绍项目中很多地方使用缓存方案有的用sp,有的用mmkv,有的用lru,有的用DataStore,有的用sqlite,如何打造通用api切换操作不同存储方案?缓存方案众多,且各自使用场景有差异,如何选择合适的缓存方式?针对不同场景选择什么缓存方式,同时思考如何替换之前老的存储方案,而不用花费很大的时间成本!针对不同的业务场景,不同的缓存方案。打造一套通用的方案屏蔽各种缓存方式的差异性,暴露给外部开发者统一的API,外部开发者简化使用,提高开发效率和使用效率……1.2 遇到问题记录记录几个常见的问题问题1:各种缓存方案,分别是如何保证数据安全的,其内部使用到了哪些锁?由于引入锁,给效率上带来了什么影响?问题2:各种缓存方案,进程不安全是否会导致数据丢失,如何处理数据丢失情况?如何处理脏数据,其原理大概是什么?问题3:各种缓存方案使用场景是什么?有什么缺陷,为了解决缺陷做了些什么?比如sp存在缺陷的替代方案是DataStore,为何这样?问题4:各种缓存方案,他们的缓存效率是怎样的?如何对比?接入该库后,如何做数据迁移,如何覆盖操作?思考一个K-V框架的设计问题1-线程安全:使用K-V存储一般会在多线程环境中执行,因此框架有必要保证多线程并发安全,并且优化并发效率;问题2-内存缓存:由于磁盘 IO 操作是耗时操作,因此框架有必要在业务层和磁盘文件之间增加一层内存缓存;问题3-事务:由于磁盘 IO 操作是耗时操作,因此框架有必要将支持多次磁盘 IO 操作聚合为一次磁盘写回事务,减少访问磁盘次数;问题4-事务串行化:由于程序可能由多个线程发起写回事务,因此框架有必要保证事务之间的事务串行化,避免先执行的事务覆盖后执行的事务;问题5-异步或同步写回:由于磁盘 IO 是耗时操作,因此框架有必要支持后台线程异步写回;有时候又要求数据读写是同步的;问题6-增量更新:由于磁盘文件内容可能很大,因此修改 K-V 时有必要支持局部修改,而不是全量覆盖修改;问题7-变更回调:由于业务层可能有监听 K-V 变更的需求,因此框架有必要支持变更回调监听,并且防止出现内存泄漏;问题8-多进程:由于程序可能有多进程需求,那么框架如何保证多进程数据同步?问题9-可用性:由于程序运行中存在不可控的异常和 Crash,因此框架有必要尽可能保证系统可用性,尽量保证系统在遇到异常后的数据完整性;问题10-高效性:性能永远是要考虑的问题,解析、读取、写入和序列化的性能如何提高和权衡;问题11-安全性:如果程序需要存储敏感数据,如何保证数据完整性和保密性;问题12-数据迁移:如果项目中存在旧框架,如何将数据从旧框架迁移至新框架,并且保证可靠性;问题13-研发体验:是否模板代码冗长,是否容易出错。各种K—V框架使用体验如何?常见存储框架设计思考导图1.3 基础概念介绍最初缓存的概念提及缓存,可能很容易想到Http的缓存机制,LruCache,其实缓存最初是针对于网络而言的,也是狭义上的缓存,广义的缓存是指对数据的复用。缓存容量,就是缓存的大小每一种缓存,总会有一个最大的容量,到达这个限度以后,那么就须要进行缓存清理了框架。这个时候就需要删除一些旧的缓存并添加新的缓存。1.4 设计目标打造通用存储库:设计一个缓存通用方案,其次,它的结构需要很简单,因为很多地方需要用到,再次,它得线程安全。灵活切换不同的缓存方式,使用简单。内部开源该库:作为技术沉淀,当作专项来推动进展。高复用低耦合,便于拓展,可快速移植,解决各个项目使用内存缓存,sp,mmkv,sql,lru,DataStore的凌乱。抽象一套统一的API接口。1.5 产生收益分析统一缓存API兼容不同存储方案打造通用api,抹平了sp,mmkv,sql,lru,dataStore等各种方案的差异性。简化开发者使用,功能强大而使用简单!02.市面存储方案2.1 缓存存储有哪些比较常见的是内存缓存以及磁盘缓存。内存缓存:这里的内存主要指的存储器缓存;磁盘缓存:这里主要指的是外部存储器,手机的话指的就是存储卡。内存缓存:通过预先消耗应用的一点内存来存储数据,便可快速的为应用中的组件提供数据,是一种典型的以空间换时间的策略。磁盘缓存:读取磁盘文件要比直接从内存缓存中读取要慢一些,而且需要在一个UI主线程外的线程中进行,因为磁盘的读取速度是不能够保证的,磁盘文件缓存显然也是一种以空间换时间的策略。二级缓存:内存缓存和磁盘缓存结合。比如,LruCache将图片保存在内存,存取速度较快,退出APP后缓存会失效;而DiskLruCache将图片保存在磁盘中,下次进入应用后缓存依旧存在,它的存取速度相比LruCache会慢上一些。2.2 缓存策略有哪些缓存的核心思想主要是什么呢一般来说,缓存核心步骤主要包含缓存的添加、获取和删除这三类操作。那么为什么还要删除缓存呢?不管是内存缓存还是硬盘缓存,它们的缓存大小都是有限的。当缓存满了之后,再想其添加缓存,这个时候就需要删除一些旧的缓存并添加新的缓存。这个跟线程池满了以后的线程处理策略相似!缓存的常见的策略有那些FIFO(first in first out):先进先出策略,相似队列。LFU(less frequently used):最少使用策略,RecyclerView的缓存采用了此策略。LRU(least recently used):最近最少使用策略,Glide在进行内存缓存的时候采用了此策略。2.3 常见存储方案内存缓存:存储在内存中,如果对象销毁则内存也会跟随销毁。如果是静态对象,那么进程杀死后内存会销毁。Map,LruCache等等磁盘缓存:后台应用有可能会被杀死,那么相应的内存缓存对象也会被销毁。当你的应用重新回到前台显示时,你需要用到缓存数据时,这个时候可以用磁盘缓存。SharedPreferences,MMKV,DiskLruCache,SqlLite,DataStore,Room,Realm,GreenDao等等2.4 市面存储方案说明内存缓存Map:内存缓存,一般用HashMap存储一些数据,主要存储一些临时的对象LruCache:内存淘汰缓存,内部使用LinkedHashMap,会淘汰最长时间未使用的对象磁盘缓存SharedPreferences:轻量级磁盘存储,一般存储配置属性,线程安全。建议不要存储大数据,不支持跨进程!MMKV:腾讯开源存储库,内部采用mmap。DiskLruCache:磁盘淘汰缓存,写入数据到file文件SqlLite:移动端轻量级数据库。主要是用来对象持久化存储。DataStore:旨在替代原有的 SharedPreferences,支持SharedPreferences数据的迁移Room/Realm/GreenDao:支持大型或复杂数据集其他开源缓存库ACache:一款高效二级存储库,采用内存缓存和磁盘缓存2.5 存储方案的不足存储方案SharedPreferences的不足1.SP用内存层用HashMap保存,磁盘层则是用的XML文件保存。每次更改,都需要将整个HashMap序列化为XML格式的报文然后整个写入文件。2.SP读写文件不是类型安全的,且没有发出错误信号的机制,缺少事务性API3.commit() / apply()操作可能会造成ANR问题存储方案MMKV的不足1.没有类型信息,不支持getAll。由于没有记录类型信息,MMKV无法自动反序列化,也就无法实现getAll接口。2.需要引入so,增加包体积:引入MMKV需要增加的体积还是不少的。3.文件只增不减:MMKV的扩容策略还是比较激进的,而且扩容之后不会主动trim size。存储方案DataStore的不足1.只是提供异步API,没有提供同步API方法。在进行大量同步存储的时候,使用runBlocking同步数据可能会卡顿。2.对主线程执行同步 I/O 操作可能会导致 ANR 或界面卡顿。可以通过从 DataStore 异步预加载数据来减少这些问题。03.存储方案原理3.1 Sp存储原理分析SharedPreferences,它是一个轻量级的存储类,特别适合用于保存软件配置参数。轻量级,以键值对的方式进行存储。采用的是xml文件形式存储在本地,程序卸载后会也会一并被清除,不会残留信息。线程安全的。它有一些弊端如下所示对文件IO读取,因此在IO上的瓶颈是个大问题,因为在每次进行get和commit时都要将数据从内存写入到文件中,或从文件中读取。多线程场景下效率较低,在get操作时,会锁定SharedPreferences对象,互斥其他操作,而当put,commit时,则会锁定Editor对象,使用写入锁进行互斥,在这种情况下,效率会降低。不支持跨进程通讯,由于每次都会把整个文件加载到内存中,不建议存储大的文件内容,比如大json。有一些使用上的建议如下建议不要存储较大数据;频繁修改的数据修改后统一提交而不是修改过后马上提交;在跨进程通讯中不去使用;键值对不宜过多读写操作性能分析第一次通过Context.getSharedPreferences()进行初始化时,对xml文件进行一次读取,并将文件内所有内容(即所有的键值对)缓到内存的一个Map中,接下来所有的读操作,只需要从这个Map中取就可以3.2 MMKV存储原理分析早期微信的需求微信聊天对话内容中的特殊字符所导致的程序崩溃是一类很常见、也很需要快速解决的问题;而哪些字符会导致程序崩溃,是无法预知的。只能等用户手机上的微信崩溃之后,再利用类似时光倒流的回溯行为,看看上次软件崩溃的最后一瞬间,用户收到或者发出了什么消息,再用这些消息中的文字去尝试复现发生过的崩溃,最终试出有问题的字符,然后针对性解决。该需求对应的技术考量考量1:把聊天页面的显示文字写到手机磁盘里,才能在程序崩溃、重新启动之后,通过读取文件的方式来查看。但这种方式涉及到io流读写,且消息多会有性能问题。考量2:App程序都崩溃了,如何保证要存储的内容,都写入到磁盘中呢?考量3:保存聊天内容到磁盘的行为,这个做成同步还是异步呢?如果是异步,如何保证聊天消息的时序性?考量4:如何存储数据是同步行为,针对群里聊天这么多消息,如何才能避免卡顿呢?考量5:存储数据放到主线程中,用户在群聊天页面猛滑消息,如何爆发性集中式对磁盘写入数据?MMKV存储框架介绍MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。MMKV设计的原理内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由操作系统负责将内存回写到文件,不必担心 crash 导致数据丢失。数据组织:数据序列化方面我们选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。写入优化:考虑到主要使用场景是频繁地进行写入更新,需要有增量更新的能力。考虑将增量 kv 对象序列化后,append 到内存末尾。空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。需要在性能和空间上做个折中。MMKV诞生的背景针对该业务,高频率,同步,大量数据写入磁盘的需求。不管用sp,还是store,还是disk,还是数据库,只要在主线程同步写入磁盘,会很卡。解决方案就是:使用内存映射mmap的底层方法,相当于系统为指定文件开辟专用内存空间,内存数据的改动会自动同步到文件里。用浅显的话说:MMKV就是实现用「写入内存」的方式来实现「写入磁盘」的目标。内存的速度多快呀,耗时几乎可以忽略,这样就把写磁盘造成卡顿的问题解决了。3.3 LruCache考量分析在LruCache的源码中,关于LruCache有这样的一段介绍:cache对象通过一个强引用来访问内容。每次当一个item被访问到的时候,这个item就会被移动到一个队列的队首。当一个item被添加到已经满了的队列时,这个队列的队尾的item就会被移除。LruCache核心思想LRU是近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LrhCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存,其核心思想都是LRU缓存算法。LruCache使用是计数or计量使用计数策略:1、Message 消息对象池:最多缓存 50 个对象;2、OkHttp 连接池:默认最多缓存 5 个空闲连接;3、数据库连接池使用计量策略:1、图片内存缓存;2、位图池内存缓存那么思考一下如何理解 计数 or 计量 ?针对计数策略使用Lru仅仅只统计缓存单元的个数,针对计量则要复杂一点。LruCache策略能否增加灵活性在缓存容量满时淘汰,除了这个策略之外,能否再增加一些辅助策略,例如在 Java 堆内存达到某个阈值后,对 LruCache 使用更加激进的清理策略。比如:Glide 除了采用 LRU 策略淘汰最早的数据外,还会根据系统的内存紧张等级 onTrimMemory(level) 及时减少甚至清空 LruCache。/**
* 这里是参考glide中的lru缓存策略,低内存的时候清除
* @param level level级别
*/
public void trimMemory(int level) {
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
clearMemory();
} else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
trimToSize(maxSize() / 2);
}
}关于Lru更多的原理解读,可以看:AppLruCache3.4 DiskLru原理分析DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache最大的特点就是持久化存储,所有的缓存以文件的形式存在。在用户进入APP时,它根据日志文件将DiskLruCache恢复到用户上次退出时的情况,日志文件journal保存每个文件的下载、访问和移除的信息,在恢复缓存时逐行读取日志并检查文件来恢复缓存。DiskLruCache缓存基础原理流程图关于DiskLruCache更多的原理解读,可以看:AppLruDisk3.5 DataStore分析为何会有DataStoreDataStore 被创造出来的目标就是替代 Sp,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。DataStore优势是异步ApiDataStore 的主要优势之一是异步API,所以本身并未提供同步API调用,但实际上可能不一定始终能将周围的代码更改为异步代码。提出一个问题和思考如果使用现有代码库采用同步磁盘 I/O,或者您的依赖项不提供异步API,那么如何将DataStore存储数据改成同步调用?使用阻塞式协程消除异步差异使用 runBlocking() 从 DataStore 同步读取数据。runBlocking()会运行一个新的协程并阻塞当前线程直到内部逻辑完成,所以尽量避免在UI线程调用。频繁使用阻塞式协程会有问题吗要注意的一点是,不用在初始读取时调用runBlocking,会阻塞当前执行的线程,因为初始读取会有较多的IO操作,耗时较长。更为推荐的做法则是先异步读取到内存后,后续有需要可直接从内存中拿,而非运行同步代码阻塞式获取。3.6 HashMap存储分析内存缓存的场景比如 SharedPreferences 存储中,就做了内存缓存的操作。3.7 Sqlite存储分析注意:缓存的数据库是存放在/data/data/databases/目录下,是占用内存空间的,如果缓存累计,容易浪费内存,需要及时清理缓存。3.8 使用缓存注意点在使用内存缓存的时候须要注意防止内存泄露,使用磁盘缓存的时候注意确保缓存的时效性针对SharedPreferences使用建议有:因为 SharedPreferences 虽然是全量更新的模式,但只要把保存的数据用合适的逻辑拆分到多个不同的文件里,全量更新并不会对性能造成太大的拖累。它设计初衷是轻量级,建议当存储文件中key-value数据超过30个,如果超过30个(这个只是一个假设),则开辟一个新的文件进行存储。建议不同业务模块的数据分文件存储……针对MMKV使用建议有:如果项目中有高频率,同步存储数据,使用MMKV更加友好。针对DataStore使用建议有:建议在初始化的时候,使用全局上下文Context给DataStore设置存储路径。针对LruCache缓存使用建议:如果你使用“计量”淘汰策略,需要重写 SystemLruCache#sizeOf() 测量缓存单元的内存占用量,否则缓存单元的大小默认视为 1,相当于 maxSize 表示的是最大缓存数量。3.9 各种数据存储文件SharedPreferences 存储文件格式如下所示<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="name">杨充</string>
<int name="age" value="28" />
<boolean name="married" value="true" />
</map>MMKV 存储文件格式如下所示MMKV的存储结构,分了两个文件,一个数据文件,一个校验文件crc结尾。大概如下所示:这种设计最直接问题就是占用空间变大了很多,举一个例子,只存储了一个字段,但是为了方便MMAP映射,磁盘直接占用了8k的存储。LruDiskCache 存储文件格式如下所示DataStore 存储文件格式如下所示04.通用缓存方案思路4.1 如何兼容不同缓存定义通用的存储接口不同的存储方案,由于api不一样,所以难以切换操作。要是想兼容不同存储方案切换,就必须自己制定一个通用缓存接口。定义接口,然后各个不同存储方案实现接口,重写抽象方法。调用的时候,获取接口对象调用api,这样就可以统一Api定义一个接口,这个接口有什么呢?主要是存和取各种基础类型数据,比如saveInt/readInt;saveString/readString等通用抽象方法4.2 打造通用缓存Api通用缓存Api设计思路:通用一套api + 不同接口实现 + 代理类 + 工厂模型定义缓存的通用API接口,这里省略部分代码interface ICacheable {
fun saveXxx(key: String, value: Int)
fun readXxx(key: String, default: Int = 0): Int
fun removeKey(key: String)
fun totalSize(): Long
fun clearData()
}基于接口而非实现编程的设计思想将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。4.3 切换不同缓存方式传入不同类型方便创建不同存储方式隐藏存储方案创建具体细节,开发者只需要关心所需产品对应的工厂,无须关心创建细节,甚至无须知道具体存储方案的类名。需要符合开闭原则那么具体该怎么实现呢?看到下面代码是不是有种很熟悉的感觉,没错,正是使用了工厂模式,灵活切换不同的缓存方式。但针对应用层调用api却感知不到影响。public static ICacheable getCacheImpl(Context context, @CacheConstants.CacheType int type) {
if (type == CacheConstants.CacheType.TYPE_DISK) {
return DiskFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_LRU) {
return LruCacheFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_MEMORY) {
return MemoryFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_MMKV) {
return MmkvFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_SP) {
return SpFactory.create().createCache(context);
} else if (type == CacheConstants.CacheType.TYPE_STORE) {
return StoreFactory.create().createCache(context);
} else {
return MmkvFactory.create().createCache(context);
}
}4.4 缓存的过期处理说一个使用场景比如你准备做WebView的资源拦截缓存,针对模版页面,为了提交加载速度。会缓存css,js,图片等资源到本地。那么如何选择存储方案,如何处理过期问题?思考一下该问题比如WebView缓存方案是数据库存储,db文件。针对缓存数据,猜想思路可能是Lru策略,或者标记时间清除过期文件。那么缓存过期处理的策略有哪些定时过期:每个设置过期时间的key都需要创建⼀个定时器,到过期时间就会立即清除。惰性过期:只有当访问⼀个 key 时,才会判断该key是否已过期,过期则清除。定期过期:每隔⼀定的时间,会扫描⼀定数量的数据库的 expires 字典中⼀定数量的key(是随机的), 并 清除其中已过期的key 。分桶策略:定期过期的优化,将过期时间点相近的 key 放在⼀起,按时间扫描分桶。4.5 缓存的阀值处理淘汰一个最早的节点就足够吗?以Lru缓存为案例做分析……标准的 LRU 策略中,每次添加数据时最多只会淘汰一个数据,但在 LRU 内存缓存中,只淘汰一个数据单元往往并不够。例如在使用 “计量” 的内存图片缓存中,在加入一个大图片后,只淘汰一个图片数据有可能依然达不到最大缓存容量限制。那么在LRUCache该如何做呢?在复用 LinkedHashMap 实现 LRU 内存缓存时,前文提到的 LinkedHashMap#removeEldestEntry() 淘汰判断接口可能就不够看了,因为它每次最多只能淘汰一个数据单元。LruCache是如何解决这个问题这个地方就需要重写LruCache中的sizeOf()方法,然后拿到key和value对象计算其内存大小。4.6 缓存的线程安全性为何要强调缓存方案线程安全性缓存虽好,用起来很快捷方便,但在使用过程中,大家一定要注意数据更新和线程安全,不要出现脏数据。针对LruCache中使用LinkedHashMap读写不安全情况保证LruCache的线程安全,在put,get等核心方法中,添加synchronized锁。这里主要是synchronized (this){ put操作 }针对DiskLruCache读写不安全的情况DiskLruCache 管理多个 Entry(key-values),因此锁粒度应该是 Entry 级别的。get 和 edit 方法都是同步方法,保证内部的 Entry Map 的安全访问,是保证线程安全的第一步。4.7 缓存数据的迁移如何将Sp数据迁移到DataStore通过属性委托的方式创建DataStore,基于已有的SharedPreferences文件进行创建DataStore。将sp文件名,以参数的形式传入preferencesDataStore,DataStore会自动将该文件中的数据进行转换。val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_info",
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, "sp_file_name"))
})如何将sp数据迁移到MMKVMMKV 提供了 importFromSharedPreferences() 函数,可以比较方便地迁移数据过来。MMKV 还额外实现了一遍 SharedPreferences、SharedPreferences.Editor 这两个 interface。MMKV preferences = MMKV.mmkvWithID("myData");
// 迁移旧数据
{
SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
preferences.importFromSharedPreferences(old_man);
old_man.edit().clear().commit();
}思考一下,MMKV框架实现了sp的两个接口,即磨平了数据迁移差异性那么使用这个方式,借鉴该思路,你能否尝试用该方法,去实现LruDiskCache方案的sp数据一键迁移。4.8 缓存数据加密思考一下,如果让你去设计数据的加密,你该怎么做?具体可以参考MMKV的数据加密过程。4.9 缓存效率的对比测试数据测试写入和读取。注意分别使用不同的方式,测试存储或获取相同的数据(数据为int类型数字,还有String类型长字符串)。然后查看耗时时间的长短……比较对象SharePreferences/DataStore/MMKV/LruDisk/Room。使用华为手机测试测试数据案例1在主线程中测试数据,同步耗时时间(主线程还有其他的耗时)跟异步场景有较大差别。测试数据案例2测试1000组长字符串数据,MMKV 就不具备优势了,反而成了耗时最久的;而这时候的冠军就成了 DataStore,并且是遥遥领先。最后思考说明从最终的数据来看,这几种方案都不是很慢。虽然这半秒左右的主线程耗时看起来很可怕,但是要知道这是 1000 次连续写入的耗时。而在真正写程序的时候,怎么会一次性做 1000 次的长字符串的写入?所以真正在项目中的键值对写入的耗时,不管你选哪个方案,都会比这份测试结果的耗时少得多的,都少到了可以忽略的程度,这是关键。05.方案基础设计5.1 整体架构图统一存储方案的架构图5.2 UML设计图通用存储方案UML设计图5.3 代码说明图项目中代码相关说明图5.4 关键流程图mmap的零拷贝流程图5.5 模块间依赖关系存储库依赖的关系MMKV需要依赖一些腾讯开源库的服务;DataStore存储需要依赖datastore相关的库;LruDisk存储需要依赖disk库如果你要拓展其他的存储方案,则需要添加其依赖。需要注意,添加的库使用compileOnly。06.其他设计说明6.1 性能设计关于基础库性能如何考量具体性能可以参考测试效率的对比。6.2 稳定性设计针对多进程初始化遇到问题:对于多进程在Application的onCreate创建几次,导致缓存存储库初始化了多次。问题分析:该场景不是该库的问题,建议判断是否是主进程,如果是则进行初始化。如何解决:思路是获取当前进程名,并与主进程对比,来判断是否为主进程。具体可以参考:优雅判断是否是主进程6.3 灰度设计暂无灰度设计6.4 降级设计由于缓存方式众多,在该库中配置了降级,如何设置降级//设置是否是debug模式
CacheConfig cacheConfig = builder.monitorToggle(new IMonitorToggle() {
@Override
public boolean isOpen() {
//todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
return true;
}
})
//创建
.build();
CacheInitHelper.INSTANCE.init(this,cacheConfig);降级后的逻辑处理是如果是降级逻辑,则默认使用谷歌官方存储框架SharedPreferences。默认是不会降级的!if (CacheInitHelper.INSTANCE.isToggleOpen()){
//如果是降级,则默认使用sp
return SpFactory.create().createCache();
}6.5 异常设计说明DataStore初始化遇到的坑遇到问题:不能将DataStore初始化代码写到Activity里面去,否则重复进入Activity并使用Preferences DataStore时,会尝试去创建一个同名的.preferences_pb文件。问题分析:SingleProcessDataStore#check(!activeFiles.contains(it)),该方法会检查如果判断到activeFiles里已经有该文件,直接抛异常,即不允许重复创建。如何解决:在项目中只在顶层调用一次 preferencesDataStore 方法,这样可以更轻松地将 DataStore 保留为单例。MMKV遇到的坑说明MMKV 是有数据损坏的概率的,MMKV 的 GitHub wiki 页面显示,微信的 iOS 版平均每天有 70 万次的数据校验不通过(即数据损坏)。6.6 兼容性设计MMKV数据迁移比较难MMKV都是按字节进行存储的,实际写入文件把类型擦除了,这也是MMKV不支持getAll的原因,虽然说getAll用的不多问题不大,但是MMKV因此就不具备导出和迁移的能力。比较好的方案是每次存储,多用一个字节来存储数据类型,这样占用的空间也不会大很多,但是具备了更好的可扩展性。6.7 自测性设计MMKV不太方便查看数据和解析数据官方目前支持了5个平台,Android、iOS、Win、MacOS、python,但是没有提供解析数据的工具,数据文件和crc都是字节码,除了中文能看出一些内容,直接查看还是存在大量乱码。比如线上出了问题,把用户的存储文件捞上来,还得替换到系统目录里,通过代码断点去看,这也太不方便了。Sp,FastSp,DiskCache,Store等支持查看文件解析数据傻瓜式的查看缓存文件,操作缓存文件。具体看该库:MonitorFileLib磁盘查看工具07.通用Api设计7.1 如何依赖该库依赖该库如下所示//通用缓存存储库,支持sp,fastsp,mmkv,lruCache,DiskLruCache等
implementation 'com.github.yangchong211.YCCommonLib:AppBaseStore:1.4.8'7.2 初始化缓存库通用存储库初始化CacheConfig.Builder builder = CacheConfig.Companion.newBuilder();
//设置是否是debug模式
CacheConfig cacheConfig = builder.debuggable(BuildConfig.DEBUG)
//设置外部存储根目录
.extraLogDir(null)
//设置lru缓存最大值
.maxCacheSize(100)
//内部存储根目录
.logDir(null)
//创建
.build();
CacheInitHelper.INSTANCE.init(MainApplication.getInstance(),cacheConfig);
//最简单的初始化
//CacheInitHelper.INSTANCE.init(CacheConfig.Companion.newBuilder().build());7.3 切换各种缓存方案如何调用api切换各种缓存方案//这里可以填写不同的type
val cacheImpl = CacheFactoryUtils.getCacheImpl(CacheConstants.CacheType.TYPE_SP)7.4 数据的存和取存储数据和获取数据//存储数据
dataCache.saveBoolean("cacheKey1",true);
dataCache.saveFloat("cacheKey2",2.0f);
dataCache.saveInt("cacheKey3",3);
dataCache.saveLong("cacheKey4",4);
dataCache.saveString("cacheKey5","doubi5");
dataCache.saveDouble("cacheKey6",5.20);
//获取数据
boolean data1 = dataCache.readBoolean("cacheKey1", false);
float data2 = dataCache.readFloat("cacheKey2", 0);
int data3 = dataCache.readInt("cacheKey3", 0);
long data4 = dataCache.readLong("cacheKey4", 0);
String data5 = dataCache.readString("cacheKey5", "");
double data6 = dataCache.readDouble("cacheKey5", 0.0);也可以通过注解的方式存储数据class NormalCache : DataCache() {
@BoolCache(KeyConstant.HAS_ACCEPTED_PARENT_AGREEMENT, false)
var hasAcceptParentAgree: Boolean by this
}
//如何使用
object CacheHelper {
//常规缓存数据,记录一些重要的信息,慎重清除数据
private val normal: NormalCache by lazy {
NormalCache().apply {
setCacheImpl(
DiskCache.Builder()
.setFileId("NormalCache")
.build()
)
}
}
fun normal() = normal
}
//存数据
CacheHelper.normal().hasAcceptParentAgree = true
//取数据
val hasAccept = CacheHelper.normal().hasAcceptParentAgree7.5 查看缓存文件数据android缓存路径查看方法有哪些呢?将手机打开开发者模式并连接电脑,在pc控制台输入cd /data/data/目录,使用adb主要是方便测试(删除,查看,导出都比较麻烦)。如何简单快速,傻瓜式的查看缓存文件,操作缓存文件,那么该项目小工具就非常有必要呢!采用可视化界面读取缓存数据,方便操作,直观也简单。一键接入该工具FileExplorerActivity.startActivity(this);开源项目地址:https://github.com/yangchong211/YCAndroidTool查看缓存文件数据如下所示7.6 如何选择合适方案比如常见的缓存、浏览器缓存、图片缓存、线程池缓存、或者WebView资源缓存等等那就可以选择LRU+缓存淘汰算法。它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。比如针对高频率,同步存储,或者跨进程等存储数据的场景那就可以选择MMKV这种存储方案。它的核心思想就是高速存储数据,且不会阻塞主线程卡顿。比如针对存储表结构,或者一对多这类的数据那就可以选择DataStore,Room,GreenDao等存储库方案。比如针对存储少量用户类数据其实也可以将json转化为字符串,然后选择sp,mmkv,lruDisk等等都可以。08.其他说明介绍8.1 遇到的坑分析Sp存储数据commit() / apply()操作可能会造成ANR问题commit()是同步提交,会在UI主线程中直接执行IO操作,当写入操作耗时比较长时就会导致UI线程被阻塞,进而产生ANR;apply()虽然是异步提交,但异步写入磁盘时,如果执行了Activity / Service中的onStop()方法,那么一样会同步等待SP写入完毕,等待时间过长时也会引起ANR问题。首先分析一下SharedPreferences源码中apply方法SharedPreferencesImpl#apply(),这个方法主要是将记录的数据同步写到Map集合中,然后在开启子线程将数据写入磁盘SharedPreferencesImpl#enqueueDiskWrite(),这个会将runnable被写入了队列,然后在run方法中写数据到磁盘QueuedWork#queue(),这个将runnable添加到sWork(LinkedList链表)中,然后通过handler发送处理队列消息MSG_RUN然后再看一下ActivityThread源码中的handlePauseActivity()、handleStopActivity()方法。ActivityThread#handlePauseActivity()/handleStopActivity(),Activity在pause和stop的时候会调用该方法ActivityThread#handlePauseActivity()#QueuedWork.waitToFinish(),这个是等待QueuedWork所有任务处理完的逻辑QueuedWork#waitToFinish(),这个里面会通过handler查询MSG_RUN消息是否有,如果有则会waiting等待那么最后得出的结论是handlePauseActivity()的时候会一直等待 apply() 方法将数据保存成功,否则会一直等待,从而阻塞主线程造成 ANR。但普通存储的场景,这种可能性很小。8.2 项目开发分享通用缓存存储库开源代码https://github.com/yangchong211/YCCommonLib/tree/master/AppBaseStore
做 SQL 性能优化真是让人干瞪眼
很多大数据计算都是用SQL实现的,跑得慢时就要去优化SQL,但常常碰到让人干瞪眼的情况。比如,存储过程中有三条大概形如这样的语句执行得很慢: select a,b,sum(x) from T group by a,b where …;
select c,d,max(y) from T group by c,d where …;
select a,c,avg(y),min(z) from T group by a,c where …;这里的T是个有数亿行的巨大表,要分别按三种方式分组,分组的结果集都不大。分组运算要遍历数据表,这三句SQL就要把这个大表遍历三次,对数亿行数据遍历一次的时间就不短,何况三遍。这种分组运算中,相对于遍历硬盘的时间,CPU计算时间几乎可以忽略。如果可以在一次遍历中把多种分组汇总都计算出来,虽然CPU计算量并没有变少,但能大幅减少硬盘读取数据量,就能成倍提速了。如果SQL支持类似这样的语法:from T --数据来自T表
select a,b,sum(x) group by a,b where … --遍历中的第一种分组
select c,d,max(y) group by c,d where … --遍历中的第二种分组
select a,c,avg(y),min(z) group by a,c where …; --遍历中的第三种分组能一次返回多个结果集,那就可以大幅提高性能了。可惜, SQL没有这种语法,写不出这样的语句,只能用个变通的办法,就是用group a,b,c,d的写法先算出更细致的分组结果集,但要先存成一个临时表,才能进一步用SQL计算出目标结果。SQL大致如下: create table T\_temp as select a,b,c,d,
sum(case when … then x else 0 end) sumx,
max(case when … then y else null end) maxy,
sum(case when … then y else 0 end) sumy,
count(case when … then 1 else null end) county,
min(case when … then z else null end) minz
group by a,b,c,d;
select a,b,sum(sumx) from T\_temp group by a,b where …;
select c,d,max(maxy) from T\_temp group by c,d where …;
select a,c,sum(sumy)/sum(county),min(minz) from T\_temp group by a,c where …; 这样只要遍历一次了,但要把不同的WHERE条件转到前面的case when里,代码复杂很多,也会加大计算量。而且,计算临时表时分组字段的个数变得很多,结果集就有可能很大,最后还对这个临时表做多次遍历,计算性能也快不了。大结果集分组计算还要硬盘缓存,本身性能也很差。还可以用存储过程的数据库游标把数据一条一条fetch出来计算,但这要全自己实现一遍WHERE和GROUP的动作了,写起来太繁琐不说,数据库游标遍历数据的性能只会更差!只能干瞪眼!TopN运算同样会遇到这种无奈。举个例子,用Oracle的SQL写top5大致是这样的: select \* from (select x from T order by x desc) where rownum<=5表T有10亿条数据,从SQL语句来看,是将全部数据大排序后取出前5名,剩下的排序结果就没用了!大排序成本很高,数据量很大内存装不下,会出现多次硬盘数据倒换,计算性能会非常差!避免大排序并不难,在内存中保持一个5条记录的小集合,遍历数据时,将已经计算过的数据前5名保存在这个小集合中,取到的新数据如果比当前的第5名大,则插入进去并丢掉现在的第5名,如果比当前的第5名要小,则不做动作。这样做,只要对10亿条数据遍历一次即可,而且内存占用很小,运算性能会大幅提升。这种算法本质上是把TopN也看作与求和、计数一样的聚合运算了,只不过返回的是集合而不是单值。SQL要是能写成这样,就能避免大排序了: select top(x,5) from T然而非常遗憾,SQL没有显式的集合数据类型,聚合函数只能返回单值,写不出这种语句!不过好在全集的TopN比较简单,虽然SQL写成那样,数据库却通常会在工程上做优化,采用上述方法而避免大排序。所以Oracle算那条SQL并不慢。但是,如果TopN的情况复杂了,用到子查询中或者和JOIN混到一起的时候,优化引擎通常就不管用了。比如要在分组后计算每组的TopN,用SQL写出来都有点困难。Oracle的SQL写出来是这样: select \* from (select y,x,row\_number() over (partition by y order by x desc) rn from T) where rn<=5这时候,数据库的优化引擎就晕了,不会再采用上面说的把TopN理解成聚合运算的办法。只能去做排序了,结果运算速度陡降!假如SQL的分组TopN能这样写: select y,top(x,5) from T group by y把top看成和sum一样的聚合函数,这不仅更易读,而且也很容易高速运算。可惜,不行。还是干瞪眼!关联计算也是很常见的情况。以订单和多个表关联后做过滤计算为例,SQL大体是这个样子:select o.oid,o.orderdate,o.amount
from orders o
left join city ci on o.cityid = ci.cityid
left join shipper sh on o.shid=sh.shid
left join employee e on o.eid=e.eid
left join supplier su on o.suid=su.suid
where ci.state='New York'
and e.title='manager'
and ... 订单表有几千万数据,城市、运货商、雇员、供应商等表数据量都不大。过滤条件字段可能会来自于这些表,而且是前端传参数到后台的,会动态变化。SQL一般采用HASH JOIN算法实现这些关联,要计算 HASH 值并做比较。每次只能解析一个JOIN,有N个JOIN要执行N遍动作,每次关联后都需要保持中间结果供下一轮使用,计算过程复杂,数据也会被遍历多次,计算性能不好。通常,这些关联的代码表都很小,可以先读入内存。如果将订单表中的各个关联字段预先做序号化处理,比如将雇员编号字段值转换为对应雇员表记录的序号。那么计算时,就可以用雇员编号字段值(也就是雇员表序号),直接取内存中雇员表对应位置的记录,性能比HASH JOIN快很多,而且只需将订单表遍历一次即可,速度提升会非常明显!也就是能把SQL写成下面的样子:select o.oid,o.orderdate,o.amount
from orders o
left join city c on o.cid = c.# --订单表的城市编号通过序号#关联城市表
left join shipper sh on o.shid=sh.# --订单表运货商号通过序号#关联运货商表
left join employee e on o.eid=e.# --订单表的雇员编号通过序号#关联雇员表
left join supplier su on o.suid=su.#--订单表供应商号通过序号#关联供应商表
where ci.state='New York'
and e.title='manager'
and ... 可惜的是,SQL 使用了无序集合概念,即使这些编号已经序号化了,数据库也无法利用这个特点,不能在对应的关联表这些无序集合上使用序号快速定位的机制,只能使用索引查找,而且数据库并不知道编号被序号化了,仍然会去计算 HASH 值和比对,性能还是很差!有好办法也实施不了,只能再次干瞪眼!还有高并发帐户查询,这个运算倒是很简单:select id,amt,tdate,… from T
where id='10100'
and tdate>= to\_date('2021-01-10','yyyy-MM-dd')
and tdate<to_date('2021-01-25','yyyy-mm-dd')
and="" …="" <p="">在T表的几亿条历史数据中,快速找到某个帐户的几条到几千条明细,SQL写出来并不复杂,难点是大并发时响应速度要达到秒级甚至更快。为了提高查询响应速度,一般都会对 T 表的 id 字段建索引:create index index_T_1 on T(id)在数据库中,用索引查找单个帐户的速度很快,但并发很多时就会明显变慢。原因还是上面提到的SQL无序理论基础,总数据量很大,无法全读入内存,而数据库不能保证同一帐户的数据在物理上是连续存放的。硬盘有最小读取单位,在读不连续数据时,会取出很多无关内容,查询就会变慢。高并发访问的每个查询都慢一点,总体性能就会很差了。在非常重视体验的当下,谁敢让用户等待十秒以上?!容易想到的办法是,把几亿数据预先按照帐户排序,保证同一帐户的数据连续存储,查询时从硬盘上读出的数据块几乎都是目标值,性能就会得到大幅提升。但是,采用SQL体系的关系数据库并没有这个意识,不会强制保证数据存储的物理次序!这个问题不是SQL语法造成的,但也和SQL的理论基础相关,在关系数据库中还是没法实现这些算法。那咋办?只能干瞪眼吗?不能再用SQL和关系数据库了,要使用别的计算引擎。开源的集算器SPL基于创新的理论基础,支持更多的数据类型和运算,能够描述上述场景中的新算法。用简单便捷的SPL写代码,在短时间内能大幅提高计算性能!上面这些问题用SPL写出来的代码样例如下:一次遍历计算多种分组 AB1=file("T.ctx").open().cursor(a,b,c,d,x,y,z 2cursor A1=A2.select(…).groups(a,b;sum(x))3 //定义遍历中的第一种过滤、分组4cursor=A4.select(…).groups(c,d;max(y))5 //定义遍历中的第二种过滤、分组6cursor=A6.select(…).groupx(a,c;avg(y),min(z))7 //定义遍历中的第三种过滤、分组8…//定义结束,开始计算三种方式的过滤、分组用聚合的方式计算Top5全集Top5(多线程并行计算)| | A | | --- | --- | |1 |=file("T.ctx").open()||2 |=A1.cursor@m(x).total(top(-5,x),top(5,x))||3 |//top(-5,x) 计算出 x 最大的前 5 名,top(5,x) 是 x 最小的前 5 名。|分组Top5(多线程并行计算)| | A | | --- | --- | |1 |=file("T.ctx").open()||2 |=A1.cursor@m(x,y).groups(y;top(-5,x),top(5,x))|用序号做关联的SPL代码:系统初始化| | A | | --- | --- | |1 |>env(city,file("city.btx").import@b()),env(employee,file("employee.btx").import@b()),...||2 |//系统初始化时,几个小表读入内存|查询| | A | | --- | --- | |1 |=file("orders.ctx").open().cursor(cid,eid,…).switch(cid,city:#;eid,employee:#;…)||2| =A1.select(cid.state=="New York" && eid.title=="manager"…)||3 |//先序号关联,再引用关联表字段写过滤条件|高并发帐户查询的SPL代码:数据预处理,有序存储| | A | B| | --- | --- | --- | |1| =file("T-original.ctx").open().cursor(id,tdate,amt,…)|||2| =A1.sortx(id)| =file("T.ctx")||3| =B2.create@r(#id,tdate,amt,…).append@i(A2)|||4| =B2.open().index(index_id;id)| ||5| //将原数据排序后,另存为新表,并为帐号建立索引||帐户查询| | A | | --- | --- | |1| =T.icursor(;id==10100 && tdate>=date("2021-01-10") && tdate<date("2021-01-25") && …,index_id).fetch()||2| //查询代码非常简单|除了这些简单例子,SPL还能实现更多高性能算法,比如有序归并实现订单和明细之间的关联、预关联技术实现多维分析中的多层维表关联、位存储技术实现上千个标签统计、布尔集合技术实现多个枚举值过滤条件的查询提速、时序分组技术实现复杂的漏斗分析等等。正在为SQL性能优化头疼的小伙伴们,可以和我们一起探讨:http://www.raqsoft.com.cn/wx/Query-run-batch-ad.htmlSPL资料SPL官网SPL下载SPL源代码