注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

元宇宙讨论

元宇宙讨论

元宇宙到底是什么?来畅所欲言
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

动态适配 web 终端的尺寸

web
使Xterminal组件自适应容器 通过 xtermjs 所创建的终端大小是由cols、rows这两个配置项的来决定,虽然你可以通过 CSS 样式来让其产生自适应效果,但是这种情况下字体会变得模糊变形等、会出现一系列的问题,要解决这个问题我们还是需要使用col...
继续阅读 »

使Xterminal组件自适应容器


通过 xtermjs 所创建的终端大小是由cols、rows这两个配置项的来决定,虽然你可以通过 CSS 样式来让其产生自适应效果,但是这种情况下字体会变得模糊变形等、会出现一系列的问题,要解决这个问题我们还是需要使用cols、rows这两个值来动态设置。


image.png


红色部分则是通过colsrows属性控制,我们可以很明显的看到该终端组件并没有继承父元素的宽度以及高度,而实际的宽度则是通过colsrows两个属性控制的。


如何动态设置cols和rows这两个参数。


我们去看官方官方文档的时候,会注意到,官方有提供几个插件供我们使用。


image.png


xterm-addon-fit: 可以帮助我们来让 web 终端实现宽度自适应容器。目前的话行数还不行,暂时没有找到好的替代方案,需要动态的计算出来,关于如何计算可以参数 vscode 官方的实现方案。


image.png


引入xterm-addon-fit,在我们的案例中,加入下面这两行:


image.png


动态计算行数


想要动态计算出行数的话,就需要获取到一个dom元素的高度:


image.png


动态计算尺寸的方法。


const terminalReact: null | HTMLDivElement = terminalRef.current // 100% * 100%
const xtermHelperReact: DOMRect | undefined = terminalReact?.querySelector(".xterm-helper-textarea")!.getBoundingClientRect()
const parentTerminalRect = terminalReact?.getBoundingClientRect()
const rows = Math.floor((parentTerminalRect ? parentTerminalRect.height : 20) / (xtermHelperReact ? xtermHelperReact.height : 1))
const cols = Math.floor((parentTerminalRect ? parentTerminalRect.width : 20) / (xtermHelperReact ? xtermHelperReact.width : 1))
// 调用resize方法,重新设置大小
termRef.current.resize(cols, rows)
复制代码

我们可以考虑封装成一个函数,只要父亲组件的大小发生变化,就动态适配一次。


作者:可视化高级双料技工
链接:https://juejin.cn/post/7160332506015727629
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Backbone前端框架解读

web
一、 什么是Backbone在前端的发展道路中,前端框架元老之一jQuery对繁琐的DOM操作进行了封装,提供了链式调用、各类选择器,屏蔽了不同浏览器写法的差异性,但是前端开发过程中依然存在作用域污染、代码复用度低、冗余度高、数据和事件绑定烦琐等痛点。5年后,...
继续阅读 »

一、 什么是Backbone

在前端的发展道路中,前端框架元老之一jQuery对繁琐的DOM操作进行了封装,提供了链式调用、各类选择器,屏蔽了不同浏览器写法的差异性,但是前端开发过程中依然存在作用域污染、代码复用度低、冗余度高、数据和事件绑定烦琐等痛点。

5年后,Backbone横空出世,通过与Underscore、Require、Handlebar的整合,提供了一个轻量和友好的前端开发解决方案,其诸多设计思想对于后续的现代化前端框架发展起到了举足轻重的作用,堪称现代前端框架的基石。

通过对Backbone前端框架的学习,让我们领略其独特的设计思想。



二、 核心架构

按照MVC框架的定义,MVC是用来将应用程序分为三个主要逻辑组件的架构模式:模型,视图和控制器。这些组件被用来处理一个面向应用的特定开发。 MVC是最常用的行业标准的Web开发框架,以创建可扩展的项目之一。 Backbone.js为复杂WEB应用程序提供模型(models)、集合(collections)、视图(views)的结构。

◦ 其中模型用于绑定键值数据,并通过RESRful JSON接口连接到应用程序;

◦ 视图用于UI界面渲染,可以声明自定义事件,通过监听模型和集合的变化执行相应的回调(如执行渲染)。





如图所示,当用户与视图层产生交互时,控制层监听变化,负责与数据层进行数据交互,触发数据Change事件,从而通知视图层重新渲染,以实现UI界面更新。更进一步,当数据层发生变化时,由Backbone提供了数据层和服务器数据共享同步的能力。

其设计思想主要包含以下几点:

◦数据绑定(依赖渲染模板引擎)、事件驱动(依赖Events)

◦视图组件化,并且组件有了生命周期的概念

◦前端路由配置化,实现页面局部刷新

这些创新的思想,在现代前端框架中进一步得到了继承和发扬。



三、 部分源码解析

Backbone极度轻量,编译后仅有几kb,贯穿其中的是大量的设计模式:工厂模式、观察者模式、迭代器模式、适配器模式……,代码流畅、实现过程比较优雅。按照功能拆分为了Events、Model、Collection、Router、History、View等若干模块,这里摘取了部分精彩源码进行了解析,相信对我们的日常代码开发也有一定指导作用:

(1)迭代器

EventsApi起到一个迭代器分流的作用,对多个事件进行解析拆分,设计的非常经典,执行时以下用法都是合法的:

◦用法一:传入一个名称和回调函数的对象

modal.on({
   "change": change_callback,
   "remove": remove_callback
})

◦用法二:使用空格分割的多个事件名称绑定到同一个回调函数上

model.on("change remove", common_callback)

实现如下:

var eventsApi = function(iteratee, events, name, callback, opts) {
  var i = 0, names;
  if(name && typeof name === 'object') {
      // 处理第一种用法
      if(callback !== void 0 && 'context' in opts && opts.context === void 0) opts.context = callback;
      for(names = _.keys(names); i < names.length; i++) events = eventsApi(iteratee, events, names[i], name[names[i]], opts);
  } else if(name && eventSplitter.test(name)) {
      // 处理第二种用法
      for(names = name.split(eventSplitter); i < names.length; i++) events = iteratee(events, names[i], callback, opts);
  } else {
      events = iteratee(events, name, callback, opts);
  }
  return events;
}

(2)监听器

用于一个对象监听另外一个对象的事件,例如,在A对象上监听在B对象上发生的事件,并且执行A的回调函数:

A.listenTo(B, "b", callback)

实际上这个功能用B对象来监听也可以实现:

B.on("b", callback, A)

这么做的好处是,方便对A创建、销毁逻辑的代码聚合,并且对B的侵入程度较小。实现如下:

Events.listenTo = function(obj, name, callback) {
   if(!obj) return this;
   var id = obj._listenId || (obj._listenId = _.uniqueId('l'));
   // 当前对象的所有监听对象
   var listeningTo = this._listeningTo || (this._listeningTo = {});
   var listening = listeningTo[id];
   
   if(!listening) {
       // 创建自身监听id
       var thisId = this._listenId || (this._listenId = _.uniqueId('l'));
       listening = listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0};
  }
   // 执行对象绑定
   internalOn(obj, name, callback, this, listening);
   return this;
}

(3)Model值set

通过option-flags兼容赋值、更新、删除等操作,这么做的好处是融合公共逻辑,简化代码逻辑和对外暴露api。实现如下:

set: function(key, val, options) {
  if(key == null) return this;
  // 支持两种赋值方式: 对象或者 key\value
  var attrs;
  if(typeof key === 'object') {
      attrs = key;
      options = val;
  } else {
      (attrs = {})[key] = val;
  }
  options || (options = {});
  ……
  var unset = options.unset;
  var silent = options.silent;
  var changes = [];
  var changing = this._changing; // 处理嵌套set
  this._changing = true;
   
  if(!changing) {
      // 存储变更前的状态快照
      this._previousAttributes = _.clone(this.attributes);
      this.changed = {};
  }
  var current = this.attributes;
  var changed = this.changed;
  var prev = this._previousAttributes;
   
  for(var attr in attrs) {
      val = attrs[attr];
      if(!_.isEqual(current[attr], val)) changes.push(attr);
      // changed只存储本次变化的key
      if(!_.isEqual(prev[attr], val)) {
          changed[attr] = val;
      } else {
          delete changed[attr]
      }
      unset ? delete current[attr] : (current[attr] = val)
  }
  if(!silent) {
      if(changes.length) this._pending = options;
      for(var i=0; i<changes.length; i++) {
          // 触发 change:attr 事件
          this.trigger('change:' + changes[i], this, current[changes[i]], options);
      }
  }
  if(changing) return this;
  if(!silent) {
      // 处理递归change场景
      while(this._pending) {
          options = this._pending;
          this._pending = false;
          this.trigger('change', this, options);
      }
  }
  this._pending = false;
  this._changing = false;
  return this;
}

四、 不足(对比react、vue)

对比现代前端框架,由于Backbone本身比较轻量,对一些内容细节处理不够细腻,主要体现在:

◦视图和数据的交互关系需要自己分类编写逻辑,需要编写较多的监听器

◦监听器数量较大,需要手动销毁,维护成本较高

◦视图树的二次渲染仅能实现组件整体替换,并非增量更新,存在性能损失

◦路由切换需要自己处理页面更新逻辑



五、为什么选择Backbone

看到这里,你可能有些疑问,既然Backbone存在这些缺陷,那么现在学习Backbone还有什么意义呢?

首先,对于服务端开发人员,Backbone底层依赖underscore/lodash、jQuery/Zepto,目前依然有很多基于Jquery和Velocity的项目需要维护,会jQuery就会Backbone,学习成本低;通过Backbone能够学习用数据去驱动View更新,优化jQuery的写法;Backbone面对对象编程,符合Java开发习惯。

其次,对于前端开发人员,能够学习其模块化封装库类函数,提升编程技艺。Backbone的组件化开发,和现代前端框架有很多共通之处,能够深入理解其演化历史。

作者:京东零售 陈震
来源:juejin.cn/post/7197075558941311035

收起阅读 »

一篇文章带你掌握Flex布局的所有用法

web
Flex 布局目前已经非常流行了,现在几乎已经兼容所有浏览器了。在文章开始之前我们需要思考一个问题:我们为什么要使用 Flex 布局?其实答案很简单,那就是 Flex 布局好用。一个新事物的出现往往是因为旧事物不那么好用了,比如,如果想让你用传统的 css 布...
继续阅读 »

Flex 布局目前已经非常流行了,现在几乎已经兼容所有浏览器了。在文章开始之前我们需要思考一个问题:我们为什么要使用 Flex 布局?

其实答案很简单,那就是 Flex 布局好用。一个新事物的出现往往是因为旧事物不那么好用了,比如,如果想让你用传统的 css 布局来实现一个块元素垂直水平居中你会怎么做?实现水平居中很简单,margin: 0 auto就行,而实现垂直水平居中则可以使用定位实现:

<div class="container">
 <div class="item"></div>
</div>
.container {
position: relative;
width: 300px;
height: 300px;
background: red;
}
.item {
position: absolute;
background: black;
width: 50px;
height: 50px;
margin: auto;
left: 0;
top: 0;
bottom: 0;
right: 0;
}

或者

.item {
 position: absolute;
 background: black;
 width: 50px;
 height: 50px;
 margin: auto;
 left: calc(50% - 25px);
 top: calc(50% - 25px);
}

image.png

但是这样都显得特别繁琐,明明可以一个属性就能解决的事情没必要写这么麻烦。而使用 Flex 则可以使用 place-content 属性简单的实现(place-content 为 justify-content 和 align-content 简写属性)

.container {
 width: 300px;
 height: 300px;
 background: red;
 display: flex;
 place-content: center;
}
.item {
 background: black;
 width: 50px;
 height: 50px;
}

接下来的本篇文章将会带领大家一起来探讨Flex布局

基本概念

我们先写一段代码作为示例(部分属性省略)

html

<div class="container">
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
 <div class="item">flex项目</div>
</div>
.container {
display: flex;
width: 800px;
gap: 10px;
}
.item {
color: #fff;
}

image.png

flex 容器

我们可以将一个元素的 display 属性设置为 flex,此时这个元素则成为flex 容器比如container元素

flex 项目

flex 容器的子元素称为flex 项目,比如item元素

flex 布局有两个轴,主轴交叉轴,至于哪个是主轴哪个是交叉轴则有flex 容器flex-direction属性决定,默认为:flex-direction:row,既横向为主轴,纵向为交叉轴,

image.png

flex-direction还可以设置其它三个属性,分别为row-reverse,column,column-reverse

  • row-reverse

image.png

  • column

1675390782104.png

  • column-reverse

1675390925377.png

从这里我们可以看出 Flex 轴的方向不是固定不变的,它受到flex-direction的影响

不足空间和剩余空间

当 Flex 项目总宽度小于 Flex 容器宽度时就会出现剩余空间

image.png

当 Flex 项目总宽度大于 Flex 容器宽度时就会出现不足空间

image.png

Flex 项目之间的间距

Flex 项目之间的间距可以直接在 Flex 容器上设置 gap 属性即可,如

<div class="container">
 <div class="item">A</div>
 <div class="item">B</div>
 <div class="item">C</div>
 <div class="item">D</div>
</div>
.container {
display: flex;
width: 500px;
height: 400px;
gap: 10px;
}
.item {
width: 150px;
height: 40px;
}

image.png

Flex 属性

flex属性是flex-grow,flex-shrink,flex-basis三个属性的简写。下面我们来看下它们分别是什么。

  • flex-basis可以设定 Flex 项目的大小,一般主轴为水平方向的话和 width 解析方式相同,但是它不一定是 Flex 项目最终大小,Flex 项目最终大小受到flex-grow,flex-shrink以及剩余空间等影响,后面文章会告诉大家最终大小的计算方式

  • flex-grow为 Flex 项目的扩展系数,当 Flex 项目总和小于 Flex 容器时就会出现剩余空间,而flex-grow的值则可以决定这个 Flex 项目可以分到多少剩余空间

  • flex-shrink为 Flex 项目的收缩系数,同样的,当 Flex 项目总和大于 Flex 容器时就会出现不足空间,flex-shrink的值则可以决定这个 Flex 项目需要减去多少不足空间

既然flex属性是这三个属性的简写,那么flex属性简写方式分别代表什么呢?

flex属性可以为 1 个值,2 个值,3 个值,接下来我们就分别来看看它们代表什么意思

  • 一个值

如果flex属性只有一个值的话,我们可以看这个值是否带单位,带单位那就是flex-basis,不带就是flex-grow

.item {
 flex: 1;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 1;
 flex-basis: 0;
}
.item {
 flex: 30px;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 1;
 flex-basis: 30px;
}
  • 两个值

flex属性有两个值的话,第一个无单位的值就是flex-grow,第一个无单位的值则是flex-shrink,有单位的就是flex-basis

.item {
 flex: 1 2;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 2;
 flex-basis: 0;
}
.item {
 flex: 30px 2;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}
  • 三个值

flex属性有三个值的话,第一个无单位的值就是flex-grow,第一个无单位的值则是flex-shrink,有单位的就是flex-basis

.item {
 flex: 1 2 10px;

 /* 相当于 */
 flex-grow: 1;
 flex-shrink: 2;
 flex-basis: 10px;
}
.item {
 flex: 30px 2 1;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}

.item {
 flex: 2 30px 1;

 /* 相当于 */
 flex-grow: 2;
 flex-shrink: 1;
 flex-basis: 30px;
}

另外,flex 的值还可以为initial,auto,none

  • initial

initial 为默认值,和不设置 flex 属性的时候表现一样,既 Flex 项目不会扩展,但会收缩,Flex 项目大小有本身内容决定

 .item {
flex: initial;

/* 相当于 */
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
}
  • auto

当 flex 设置为 auto 时,Flex 项目会根据自身内容确定flex-basis,既会拓展也会收缩

 .item {
flex: auto;

/* 相当于 */
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
}
  • none

none 表示 Flex 项目既不收缩,也不会扩展

 .item {
flex: none;

/* 相当于 */
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
}

Flex 项目大小的计算

首先看一下 flex-grow 的计算方式

flex-grow

面试中经常问到: 为什么 flex 设置为 1 的时候,Flex 项目就会均分 Flex 容器? 其实 Flex 项目设置为 1 不一定会均分容器(后面会解释),这里我们先看下均分的情况是如何发生的

同样的我们先举个例子

<div>
<div>Xiaoyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehuhu</div>
</div>
.container {
display: flex;
width: 800px;
}
.item {
flex: 1;
font-size: 30px;
}

flex 容器总宽度为 800px,flex 项目设置为flex:1,此时页面上显示

image.png

我们可以看到每个项目的宽度为 800/5=160,下面来解释一下为什么会均分:

首先

 .item {
flex: 1;

/* 相当于 */
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
}

因为flex-basis为 0,所有 Flex 项目扩展系数都是 1,所以它们分到的剩余空间都是一样的。下面看一下是如何计算出最终项目大小的

这里先给出一个公式:

Flex项目弹性量 = (Flex容器剩余空间/所有flex-grow总和)\*当前Flex项目的flex-grow

其中Flex项目弹性量指的是分配给 Flex 项目多少的剩余空间,所以 Flex 项目的最终宽度为

flex-basis+Flex项目弹性量

根据这个公式,上面的均分也就很好理解了,因为所有的flex-basis为 0,所以剩余空间就是 800px,每个 Flex 项目的弹性量也就是(800/1+1+1+1+1)*1=160,那么最终宽度也就是160+0=160

刚刚说过 flex 设置为 1 时 Flex 项目并不一定会被均分,下面就来介绍一下这种情况,我们修改一下示例中的 html,将第一个 item 中换成一个长单词

<div>
<div>Xiaoyueyueyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehu</div>
</div>

此时会发现 Flex 容器并没有被均分

image.png

因为计算出的灵活性 200px 小于第一个 Flex 项目的min-content(217.16px),此时浏览器会采用 Flex 项目的min-content作为最终宽度,而后面的 Flex 项目会在第一个 Flex 项目计算完毕后再进行同样的计算

我们修改一下 flex,给它设置一个 flex-basis,看下它计算之后的情况

.item {
text-align: center;
flex: 1 100px;
}

因为每个项目的flex-basis都是 100px,Flex 容器剩余空间800-500=300px,所以弹性量就是(300/5)*1=60px,最终宽度理论应该为100+60=160px,同样的因为第一个 Flex 项目的min-content为 217.16px,所以第一个 Flex 项目宽度被设置为 217.16px,最终表现和上面一样

image.png

我们再来看一下为什么第 2,3,4,5 个 Flex 项目宽度为什么是 145.71px

1675415296477.png

当浏览器计算完第一个 Flex 项目为 217.16px 后,此时的剩余空间为800-217.16-100*4=182.84,第 2 个 Flex 项目弹性量(182.84/1+1+1+1)*1=45.71,所以最终宽度为100+45.71=145.71px,同样的后面的 Flex 项目计算方式是一样的,但是如果后面再遇到长单词,假如第五个是长单词,那么不足空间将会发生变化,浏览器会将第五个 Flex 项目宽度计算完毕后再回头进行一轮计算,具体情况这里不再展开

所以说想要均分 Flex 容器 flex 设置为 1 并不能用在所有场景中,其实当 Flex 项目中有固定宽度元素也会出现这种情况,比如一张图片等,当然如果你想要解决这个问题其实也很简单,将 Flex 项目的min-width设置为 0 即可

.item {
flex: 1 100px;
min-width: 0;
}

image.png

flex-grow 为小数

flex-grow 的值不仅可以为正整数,还可以为小数,当为小数时也分为两种情况:所有 Flex 项目的 flex-grow 之和小于等于 1 和大于 1,我们先看小于等于 1 的情况,将例子的改成

<div>
<div>Acc</div>
<div>Bc</div>
<div>C</div>
<div>DDD</div>
<div>E</div>
</div>
.item:nth-of-type(1) {
flex-grow: 0.1;
}
.item:nth-of-type(2) {
flex-grow: 0.2;
}
.item:nth-of-type(3) {
flex-grow: 0.2;
}
.item:nth-of-type(4) {
flex-grow: 0.1;
}
.item:nth-of-type(5) {
flex-grow: 0.1;
}

效果如图

image.png

我们可以发现项目并没有占满容器,它的每个项目的弹性量计算方式为

Flex项目弹性量=Flex容器剩余空间*当前Flex项目的flex-grow

相应的每个项目的实际宽度也就是flex-basis+弹性量,首先先不设置 flex-grow,我们可以看到每个项目的 flex-basis 分别为: 51.2 , 33.88 , 20.08 , 68.56 , 16.5

image.png

所以我们可以计算出 Flex 容器的剩余空间为800-51.2 -33.88 - 20.08 - 68.56 - 16.5=609.78,这样我们就可以算出每个项目的实际尺寸为

A: 实际宽度 = 51.2 + 609.78*0.1 = 112.178

B: 实际宽度 = 33.88 + 609.78*0.2 = 155.836

...

下面看下 flex-grow 之和大于 1 的情况,将例子中的 css 改为

.item:nth-of-type(1) {
flex-grow: 0.1;
}
.item:nth-of-type(2) {
flex-grow: 0.2;
}
.item:nth-of-type(3) {
flex-grow: 0.3;
}
.item:nth-of-type(4) {
flex-grow: 0.4;
}
.item:nth-of-type(5) {
flex-grow: 0.5;
}

此时的效果为

image.png

可以看出 Flex 项目是占满容器的,它的计算方式其实和 flex-grow 为正整数时一样

Flex项目弹性量 = (Flex容器剩余空间/所有flex-grow总和)\*当前Flex项目的flex-grow

所以我们可以得出一个结论: Flex 项目的 flex-grow 之和小于 1,Flex 项目不会占满 Flex 容器

flex-shrink

flex-shrink 其实和 flex-grow 基本一样,就是扩展变成了收缩,flex-grow 是项目比例增加容器剩余空间,而 flex-shrink 则是比例减去容器不足空间

修改一下我们的例子:

.item {
flex-basis: 200px;
/* 相当于 */
flex-shrink: 1;
flex-grow: 0;
flex-basis: 200px;
}

此时项目的总宽度200*5=1000px已经大于容器总宽度800px,此时计算第一个项目的不足空间就是800-200*5=-200px,第二个项目的不足空间则是800-第一个项目实际宽度-200*4,依次类推

最终计算公式其实和 flex-grow 计算差不多

Flex项目弹性量 = (Flex容器不足空间/所有flex-shrink总和)*当前Flex项目的flex-shrink

只不过,所以上面例子每个项目可以计算出实际宽度为

第一个 Flex 项目: 200+((800-200x5)/5)*1 = 160px

第二个 Flex 项目: 200+((800-160-200x4)/4)*1 = 160px

第三个 Flex 项目: 200+((800-160-160-200x3)/3)*1 = 160px

第四个 Flex 项目: 200+((800-160-160-160-200x2)/2)*1 = 160px

第五个 Flex 项目: 200+((800-160-160-160-160-200x1)/1)*1 = 160px

如果 Flex 项目的min-content大于flex-basis,那么最终的实际宽度将会取该项目的min-content,比如改一下例子,将第一个 Flex 项目改成长单词

<div>
<div>XiaoyueXiaoyue</div>
<div>June</div>
<div>Alice</div>
<div>Youhu</div>
<div>Liehu</div>
</div>

image.png

可以看出浏览器最终采用的是第一个 Flex 项目的min-content作为实际宽度,相应的后面 Flex 项目的宽度会等前一个 Flex 项目计算完毕后在进行计算

比如第二个 Flex 项目宽度= 200+((800-228.75-200x4)/4)*1 = 142.81px

flex-shrink 为小数

同样的 flex-shrink 也会出现小数的情况,也分为 Flex 项目的 flex-shrink 之和小于等于 1 和大于 1 两种情况,如果大于 1 和上面的计算方式一样,所以我们只看小于 1 的情况,将我们的例子改为

.item {
flex-basis: 200px;
flex-shrink: 0.1;
}

效果为

image.png

此时我们会发现 Flex 项目溢出了容器,所以我们便可以得出一个结论:Flex 项目的 flex-shrink 之和小于 1,Flex 项目会溢出 Flex 容器

下面看一下它的计算公式

Flex项目弹性量=Flex容器不足空间*当前Flex项目的flex-shrink
Flex项目实际宽度=flex-basis + Flex项目弹性量

比如上面例子的每个 Flex 项目计算结果为

第一个 Flex 项目宽度 = 200+(800-200x5)x0.1=180px,但是由于它本身的min-content为 228.75,所以最终宽度为 228.75

第二个 Flex 项目宽度 =200-(800-228.75-200x4)x0.1=117.125

第三个 Flex 项目宽度...

Flex 的对齐方式

Flex 中关于对齐方式的属性有很多,其主要分为两种,一是主轴对齐方式:justify-,二是交叉轴对齐方式:align-

首先改一下我们的例子,将容器设置为宽高为 500x400 的容器(部分属性省略)

<div>
<div>A</div>
<div>B</div>
<div>C</div>
</div>
.container {
display: flex;
width: 500px;
height: 400px;
}
.item {
width: 100px;
height: 40px;
}

image.png

主轴对齐属性

这里以横向为主轴,纵向为交叉轴

justify-content

justify-content的值可以为:

  • flex-start 默认值,主轴起点对齐

image.png

  • flex-end 主轴终点对齐

image.png

  • left 默认情况下和 flex-start 一致

  • right 默认情况下和 flex-end 一致

  • center 主轴居中对齐

image.png

  • space-between 主轴两端对齐,并且 Flex 项目间距相等

image.png

  • space-around 项目左右周围空间相等

image.png

  • space-evenly 任何两个项目之间的间距以及边缘的空间相等

image.png

交叉轴对齐方式

align-content

align-content 属性控制整个 Flex 项目在 Flex 容器中交叉轴的对齐方式

注意设置 align-content 属性时候必须将 flex-wrap 设置成 wrap 或者 wrap-reverse。它可以取得值为

  • stretch 默认值,当我们 Flex 元素不设置高度的时候,默认是拉伸的

比如将 Flex 元素宽度去掉

.item {
width: 100px;
}

image.png

  • flex-start 位于容器开头,这个和 flex-direction:属性有关,默认在顶部

image.png

  • flex-end 位于容器结尾

image.png

  • center 元素居中对齐

image.png

  • space-between 交叉轴上下对齐,并且 Flex 项目上下间距相等

此时我们改下例子中 Flex 项目的宽度使其换行,因为如果 Flex 项目只有一行,那么 space-between 与 flex-start 表现一致

.item {
width: 300px;
}

image.png

  • space-around 项目上下周围空间相等

image.png

  • space-evenly 任何两个项目之间的上下间距以及边缘的空间相等

image.png

align-items

align-items 属性定义 flex 子项在 flex 容器的当前行的交叉轴方向上的对齐方式。它与 align-content 有相似的地方,它的取值有

  • stretch 默认值,当我们 Flex 元素不设置高度的时候,默认是拉伸的

  • center 元素位于容器的中心,每个当前行在图中已经框起来

image.png

  • flex-start 位于容器开头

  • flex-end 位于容器结尾

  • baseline 位于容器的基线上

比如给 A 项目一个 padding-top

.item:nth-of-type(1) {
padding-top: 50px;
}

没设置 baseline 的表现

image.png

设置 baseline 之后

image.png

通过上面的例子我们可以发现,如果想要整个 Flex 项目垂直对齐,在只有一行的情况下,align-items 和 align-content 设置为 center 都可以做到,但是如果出现多行的情况下 align-items 就不再适用了

align-self

上面都是给 Flex 容器设置的属性,但是如果想要控制单个 Flex 项目的对齐方式该怎么办呢?

其实 Flex 布局中已经考虑到了这个问题,于是就有个 align-self 属性来控制单个 Flex 项目在 Flex 容器侧交叉轴的对齐方式。

align-self 和 align-items 属性值几乎是一致的,比如我们将整个 Flex 项目设置为 center,第二个 Flex 项目设置为 flex-start

.container {
display: flex;
width: 500px;
height: 400px;
align-items: center;
}
.item {
width: 100px;
height: 40px;
}
.item:nth-of-type(2) {
align-self: flex-start;
}

image.png

注意,除了以上提到的属性的属性值,还可以设置为 CSS 的关键词如 inherit 、initial 等

交叉轴与主轴简写

place-content

place-content` 为 `justify-content` 和 `align-content` 的简写形式,可以取一个值和两个值,如果设置一个值那么 `justify-content` 和 `align-content` 都为这个值,如果是两个值,第一个值为 `align-content`,第二个则是 `justify-content

到这里关于Flex布局基本已经介绍完了,肯定会有些细枝末节没有考虑到,这可能就需要我们在平时工作和学习中去发现了

作者:东方小月
来源:https://juejin.cn/post/7197229913156796472

收起阅读 »

百万级数据excel导出功能如何实现?

前言最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。咋一看,这个需求挺...
继续阅读 »

前言

最近我做过一个MySQL百万级别数据的excel导出功能,已经正常上线使用了。

这个功能挺有意思的,里面需要注意的细节还真不少,现在拿出来跟大家分享一下,希望对你会有所帮助。

原始需求:用户在UI界面上点击全部导出按钮,就能导出所有商品数据。

咋一看,这个需求挺简单的。

但如果我告诉你,导出的记录条数,可能有一百多万,甚至两百万呢?

这时你可能会倒吸一口气。

因为你可能会面临如下问题:

  1. 如果同步导数据,接口很容易超时。

  2. 如果把所有数据一次性装载到内存,很容易引起OOM。

  3. 数据量太大sql语句必定很慢。

  4. 相同商品编号的数据要放到一起。

  5. 如果走异步,如何通知用户导出结果?

  6. 如果excel文件太大,目标用户打不开怎么办?

我们要如何才能解决这些问题,实现一个百万级别的excel数据快速导出功能呢?


1.异步处理

做一个MySQL百万数据级别的excel导出功能,如果走接口同步导出,该接口肯定会非常容易超时

因此,我们在做系统设计的时候,第一选择应该是接口走异步处理。

说起异步处理,其实有很多种,比如:使用开启一个线程,或者使用线程池,或者使用job,或者使用mq等。

为了防止服务重启时数据的丢失问题,我们大多数情况下,会使用job或者mq来实现异步功能。

1.1 使用job

如果使用job的话,需要增加一张执行任务表,记录每次的导出任务。

用户点击全部导出按钮,会调用一个后端接口,该接口会向表中写入一条记录,该记录的状态为:待执行

有个job,每隔一段时间(比如:5分钟),扫描一次执行任务表,查出所有状态是待执行的记录。

然后遍历这些记录,挨个执行。

需要注意的是:如果用job的话,要避免重复执行的情况。比如job每隔5分钟执行一次,但如果数据导出的功能所花费的时间超过了5分钟,在一个job周期内执行不完,就会被下一个job执行周期执行。

所以使用job时可能会出现重复执行的情况。

为了防止job重复执行的情况,该执行任务需要增加一个执行中的状态。

具体的状态变化如下:

  1. 执行任务被刚记录到执行任务表,是待执行状态。

  2. 当job第一次执行该执行任务时,该记录再数据库中的状态改为:执行中

  3. 当job跑完了,该记录的状态变成:完成失败

这样导出数据的功能,在第一个job周期内执行不完,在第二次job执行时,查询待处理状态,并不会查询出执行中状态的数据,也就是说不会重复执行。

此外,使用job还有一个硬伤即:它不是立马执行的,有一定的延迟。

如果对时间不太敏感的业务场景,可以考虑使用该方案。

1.2 使用mq

用户点击全部导出按钮,会调用一个后端接口,该接口会向mq服务端,发送一条mq消息

有个专门的mq消费者,消费该消息,然后就可以实现excel的数据导出了。

相较于job方案,使用mq方案的话,实时性更好一些。

对于mq消费者处理失败的情况,可以增加补偿机制,自动发起重试

RocketMQ自带了失败重试功能,如果失败次数超过了一定的阀值,则会将该消息自动放入死信队列

2.使用easyexcel

我们知道在Java中解析和生成Excel,比较有名的框架有Apache POIjxl

但它们都存在一个严重的问题就是:非常耗内存,POI有一套SAX模式的API可以一定程度的解决一些内存溢出的问题,但POI还是有一些缺陷,比如07版Excel解压缩以及解压后存储都是在内存中完成的,内存消耗依然很大。

百万级别的excel数据导出功能,如果使用传统的Apache POI框架去处理,可能会消耗很大的内存,容易引发OOM问题。

easyexcel重写了POI对07版Excel的解析,之前一个3M的excel用POI sax解析,需要100M左右内存,如果改用easyexcel可以降低到几M,并且再大的Excel也不会出现内存溢出;03版依赖POI的sax模式,在上层做了模型转换的封装,让使用者更加简单方便。

需要在mavenpom.xml文件中引入easyexcel的jar包:

<dependency>
   <groupId>com.alibaba</groupId>
   <artifactId>easyexcel</artifactId>
   <version>3.0.2</version>
</dependency>
复制代码

之后,使用起来非常方便。

读excel数据非常方便:

@Test
public void simpleRead() {
  String fileName = TestFileUtil.getPath() + "demo" + File.separator + "demo.xlsx";
  // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
  EasyExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
复制代码

写excel数据也非常方便:

 @Test
public void simpleWrite() {
   String fileName = TestFileUtil.getPath() + "write" + System.currentTimeMillis() + ".xlsx";
   // 这里 需要指定写用哪个class去读,然后写到第一个sheet,名字为模板 然后文件流会自动关闭
   // 如果这里想使用03 则 传入excelType参数即可
   EasyExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
}
复制代码

easyexcel能大大减少占用内存的主要原因是:在解析Excel时没有将文件数据一次性全部加载到内存中,而是从磁盘上一行行读取数据,逐个解析。

3.分页查询

百万级别的数据,从数据库一次性查询出来,是一件非常耗时的工作。

即使我们可以从数据库中一次性查询出所有数据,没出现连接超时问题,这么多的数据全部加载到应用服务的内存中,也有可能会导致应用服务出现OOM问题。

因此,我们从数据库中查询数据时,有必要使用分页查询。比如:每页5000条记录,分为200页查询。

public Page<User> searchUser(SearchModel searchModel) {
  List<User> userList = userMapper.searchUser(searchModel);
  Page<User> pageResponse = Page.create(userList, searchModel);
  pageResponse.setTotal(userMapper.searchUserCount(searchModel));
  return pageResponse;
}
复制代码

每页大小pageSize和页码pageNo,是SearchModel类中的成员变量,在创建searchModel对象时,可以设置设置这两个参数。

然后在Mybatis的sql文件中,通过limit语句实现分页功能:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

4.多个sheet

我们知道,excel对一个sheet存放的最大数据量,是有做限制的,一个sheet最多可以保存1048576行数据。否则在保存数据时会直接报错:

invalid row number (1048576) outside allowable range (0..1048575)
复制代码

如果你想导出一百万以上的数据,excel的一个sheet肯定是存放不下的。

因此我们需要把数据保存到多个sheet中。

5.计算limit的起始位置

我之前说过,我们一般是通过limit语句来实现分页查询功能的:

limit #{pageStart}, #{pageSize}
复制代码

其中的pagetStart参数,是通过pageNo和pageSize动态计算出来的,比如:

pageStart = (pageNo - 1) * pageSize;
复制代码

如果只有一个sheet可以这么玩,但如果有多个sheet就会有问题。因此,我们需要重新计算limit的起始位置。

例如:

ExcelWriter excelWriter = EasyExcelFactory.write(out).build();
int totalPage = searchUserTotalPage(searchModel);

if(totalPage > 0) {
  Page<User> page = Page.create(searchModel);
  int sheet = (totalPage % maxSheetCount == 0) ? totalPage / maxSheetCount: (totalPage / maxSheetCount) + 1;
  for(int i=0;i<sheet;i++) {
    WriterSheet writeSheet = buildSheet(i,"sheet"+i);
    int startPageNo = i*(maxSheetCount/pageSize)+1;
    int endPageNo = (i+1)*(maxSheetCount/pageSize);
    while(page.getPageNo()>=startPageNo && page.getPageNo()<=endPageNo) {
      page = searchUser(searchModel);
      if(CollectionUtils.isEmpty(page.getList())) {
          break;
      }
       
      excelWriter.write(page.getList(),writeSheet);
      page.setPageNo(page.getPageNo()+1);
    }
  }
}
复制代码

这样就能实现分页查询,将数据导出到不同的excel的sheet当中。

6.文件上传到OSS

由于现在我们导出excel数据的方案改成了异步,所以没法直接将excel文件,同步返回给用户。

因此我们需要先将excel文件存放到一个地方,当用户有需要时,可以访问到。

这时,我们可以直接将文件上传到OSS文件服务器上。

通过OSS提供的上传接口,将excel上传成功后,会返回文件名称访问路径

我们可以将excel名称和访问路径保存到中,这样的话,后面就可以直接通过浏览器,访问远程excel文件了。

而如果将excel文件保存到应用服务器,可能会占用比较多的磁盘空间

一般建议将应用服务器文件服务器分开,应用服务器需要更多的内存资源或者CPU资源,而文件服务器需要更多的磁盘资源

7.通过WebSocket推送通知

通过上面的功能已经导出了excel文件,并且上传到了OSS文件服务器上。

接下来的任务是要本次excel导出结果,成功还是失败,通知目标用户。

有种做法是在页面上提示:正在导出excel数据,请耐心等待

然后用户可以主动刷新当前页面,获取本地导出excel的结果。

但这种用户交互功能,不太友好。

还有一种方式是通过webSocket建立长连接,进行实时通知推送。

如果你使用了SpringBoot框架,可以直接引入webSocket的相关jar包:

<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
复制代码

使用起来挺方便的。

我们可以加一张专门的通知表,记录通过webSocket推送的通知的标题、用户、附件地址、阅读状态、类型等信息。

能更好的追溯通知记录。

webSocket给客户端推送一个通知之后,用户的右上角的收件箱上,实时出现了一个小窗口,提示本次导出excel功能是成功还是失败,并且有文件下载链接。

当前通知的阅读状态是未读

用户点击该窗口,可以看到通知的详细内容,然后通知状态变成已读

8.总条数可配置

我们在做导百万级数据这个需求时,是给用户用的,也有可能是给运营同学用的。

其实我们应该站在实际用户的角度出发,去思考一下,这个需求是否合理。

用户拿到这个百万级别的excel文件,到底有什么用途,在他们的电脑上能否打开该excel文件,电脑是否会出现太大的卡顿了,导致文件使用不了。

如果该功能上线之后,真的发生发生这些情况,那么导出excel也没有啥意义了。

因此,非常有必要把记录的总条数,做成可配置的,可以根据用户的实际情况调整这个配置。

比如:用户发现excel中有50万的数据,可以正常访问和操作excel,这时候我们可以将总条数调整成500000,把多余的数据截取掉。

其实,在用户的操作界面,增加更多的查询条件,用户通过修改查询条件,多次导数据,可以实现将所有数据都导出的功能,这样可能更合理一些。

此外,分页查询时,每页的大小,也建议做成可配置的。

通过总条数和每页大小,可以动态调整记录数量和分页查询次数,有助于更好满足用户的需求。

9.order by商品编号

之前的需求是要将相同商品编号的数据放到一起。

例如:

编号商品名称仓库名称价格
1笔记本北京仓7234
1笔记本上海仓7235
1笔记本武汉仓7236
2平板电脑成都仓7236
2平板电脑大连仓3339

但我们做了分页查询的功能,没法将数据一次性查询出来,直接在Java内存中分组或者排序。

因此,我们需要考虑在sql语句中使用order by 商品编号,先把数据排好顺序,再查询出数据,这样就能将相同商品编号,仓库不同的数据放到一起。

此外,还有一种情况需要考虑一下,通过配置的总记录数将全部数据做了截取。

但如果最后一个商品编号在最后一页中没有查询完,可能会导致导出的最后一个商品的数据不完整。

因此,我们需要在程序中处理一下,将最后一个商品删除。

但加了order by关键字进行排序之后,如果查询sql中join了很多张表,可能会导致查询性能变差。

那么,该怎么办呢?

总结

最后用两张图,总结一下excel异步导数据的流程。

如果是使用mq导数据:


如果是使用job导数据:


这两种方式都可以,可以根据实际情况选择使用。

我们按照这套方案的开发了代码,发到了pre环境,原本以为会非常顺利,但后面却还是出现了性能问题。

后来,我们用了两招轻松解决了性能问题。

作者:苏三说技术
来源:juejin.cn/post/7196140566111043643

收起阅读 »

对于单点登录,你不得不了解的CAS

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS。寒暄开始今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和...
继续阅读 »

之前我们通过面试的形式,讲了JWT实现单点登录(SSO)的设计思路,并且到最后也留下了疑问,什么是CAS

寒暄开始

今天是上班的第一天,刚进公司就见到了上次的面试官,穿着格子衬衫和拖鞋,我们就叫他老余吧。老余看见我就开始勾肩搭背聊起来了,完全就是自来熟的样子,和我最近看的少年歌行里的某人很像。

什么是CAS呢

老余:上次你说到了CAS,你觉得CAS是什么?

我:之前我们面试的时候,我讲了JWT单点登录带来的问题,然后慢慢优化,最后衍变成了中心化单点登录系统,也就是CAS的方案。

CAS(Central Authentication Service),中心认证服务,就是单点登录的某种实现方案。你可以把它理解为它是一个登录中转站,通过SSO站点,既解决了Cookie跨域的问题,同时还通过SSO服务端实现了登录验证的中心化。

这里的SSO指的是:SSO系统

它的设计流程是怎样的

老余:你能不能讲下它的大致实现思路,这说的也太虚头巴脑了,简直是听君一席话,如听一席话。
我:你别急呀,先看下它的官方流程图。


重定向到SSO

首先,用户想要访问系统A的页面1,自然会调用http://www.chezhe1.com的限制接口,(比如说用户信息等接口登录后才能访问)。

接下来 系统A 服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。如果未登录,则重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。此时回调地址是:http://www.sso.com?url=www.chezhe1.com

这个回调地址大家应该都不会陌生吧,像那种异步接口或者微信授权、支付都会涉及到这块内容。不是很了解的下面会解释~
另外这个回调地址还必须是前端页面地址,主要用于回调后和当前系统建立会话。

此时如下图所示:


用户登录

  1. 在重定向到SSO登录页后,需要在页面加载时调用接口,根据SessionId判断当前用户在SSO系统下是否已登录。【注意这时候已经在 SSO 系统的域名下了,也就意味着此时Cookie中的domain已经变成了sso.com

为什么又要判断是否登录?因为在 CAS 这个方案中,只有在SSO系统中为登录状态才能表明用户已登录。

  1. 如果未登录,展现账号密码框,让用户输入后进行SSO系统的登录。登录成功后,SSO页面和SSO服务端建立起了会话。 此时流程图如下所示:


安全验证

老余:你这里有一个很大的漏洞你发现没有?
我:emm,我当然知道。

对于中心化系统,我们一般会分发对应的AppId,然后要求每个应用设置白名单域名。所以在这里我们还得验证AppId的有效性,白名单域名和回调地址域名是否匹配。否则有些人在回调地址上写个黄色网站那不是凉凉。


获取用户信息登录

  1. 在正常的系统中用户登录后,一般需要跳转到业务界面。但是在SSO系统登录后,需要跳转到原先的系统A,这个系统A地址怎么来?还记得重定向到SSO页面时带的回调地址吗?


通过这个回调地址,我们就能很轻易的在用户登录成功后,返回到原先的业务系统。

  1. 于是用户登录成功后根据回调地址,带上ticket,重定向回系统A,重定向地址为:http://www.chezhe1.com?ticket=123456a

  2. 接着根据ticket,从SSO服务端中获取Token。在此过程中,需要对ticket进行验证。

  3. 根据tokenSSO服务端中获取用户信息。在此过程中,需要对token进行验证。

  4. 获取用户信息后进行登录,至此系统A页面和系统A服务端建立起了会话,登录成功。

此时流程图如下所示:


别以为这么快就结束了哦,我这边提出几个问题,只有把这些想明白了,才算是真的清楚了。

  • 为什么需要 Ticket?

  • 验证 Ticket 需要验证哪些内容?

  • 为什么需要 Token?

  • 验证 Token 需要验证哪些内容?

  • 如果没有Token,我直接通过Ticket 获取用户信息是否可行?

为什么需要 Ticket

我们可以反着想,如果没有Ticket,我们该用哪种方式获取Token或者说用户信息?你又该怎么证明你已经登录成功?用Cookie吗,明显是不行的。

所以说,Ticket是一个凭证,是当前用户登录成功后的产物。没了它,你证明不了你自己。

验证 Ticket 需要验证哪些内容

  1. 签名:对于这种中心化系统,为了安全,绝大数接口请求都会有着验签机制,也就是验证这个数据是否被篡改。至于验签的具体实现,五花八门都有。

  2. 真实性:验签成功后拿到Ticket,需要验证Ticket是否是真实存在的,不能说随便造一个我就给你返回Token吧。

  3. 使用次数:为了安全性,Ticket只能使用一次,否则就报错,因为Ticket很多情况下是拼接在URL上的,肉眼可见。

  4. 有效期:另外则是Ticket的时效,超过一定时间内,这个Ticket会过期。比如微信授权的Code只有5分钟的有效期。

  5. ......

为什么需要 Token?

只有通过Token我们才能从SSO系统中获取用户信息,但是为什么需要Token呢?我直接通过Ticket获取用户信息不行吗?

答案当然是不行的,首先为了保证安全性Ticket只能使用一次,另外Ticket具有时效性。但这与某些系统的业务存在一定冲突。因此通过使用Token增加有效时间,同时保证重复使用。

验证 Token 需要验证哪些内容?

和验证 Ticket类似

  1. 签名 2. 真实性 3. 有效期

如果没有 Token,我直接通过 Ticket 获取用户信息是否可行?

这个内容其实上面已经给出答案了,从实现上是可行的,从设计上不应该,因为TicketToken的职责不一样,Ticket 是登录成功的票据,Token是获取用户信息的票据。

用户登录系统B流程

老余:系统A登录成功后,那系统B的流程呢?
我:那就更简单了。

比如说此时用户想要访问系统B,http://www.chezhe2.com的限制接口,系统B服务端一般会在拦截器【也可以是过滤器,AOP 啥的】中根据Cookie中的SessionId判断用户是否已登录。此时在系统B中该系统肯定未登录,于是重定向到SSO系统的登录页面,并且带上自己的回调地址,便于用户在SSO系统登录成功后返回。回调地址是:http://www.sso.com?url=www.chezhe2.com。

我们知道之前SSO页面已经与SSO服务端建立了会话,并且因为CookieSSO这个域名下是共享的,所以此时SSO系统会判断当前用户已登录。然后就是之前的那一套逻辑了。 此时流程图如下所示:


技术以外的事

老余:不错不错,理解的还可以。你发现这套系统里,做的最多的是什么,有什么技术之外的感悟没。说到这,老余叹了口气。

我:我懂,做的最多的就是验证了,验证真实性、有效性、白名单这些。明明一件很简单的事,最后搞的那么复杂。像现在银行里取钱一样,各种条条框框的限制。我有时候会在想,技术发展、思想变革对于人类文明毋庸置疑是有益的,但是对于我们人类真的是一件好事吗?如果我们人类全是机器人那样的思维是不是会更好点?


老余:我就随便一提,你咋巴拉巴拉了这么多。我只清楚一点,拥有七情六欲的人总是好过没有情感的机器人的。好了,干活去吧。

总结

这一篇内容就到这了,我们聊了下关于单点登录的 CAS 设计思路,其实CAS 往大了讲还能讲很多,可惜我的技术储备还不够,以后有机会补充。如果想理解的更深刻,也可以去看下微信授权流程,应该会有帮助。

最后还顺便提了点技术之外的事,记得有句话叫做:科学的尽头是哲学,我好像开始慢慢理解这句话的意思了。

作者:车辙cz
来源:juejin.cn/post/7196924295310262328

收起阅读 »

咱不吃亏,也不能过度自卫

我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。小刘一听,感觉自己有被指控的风险。他立刻严厉起来:“每天都来公司,不一定就算全勤。没打卡我是不统计的”。最后小刘一查,发现是自己统计错了。小刘反而更加强势了:“这种事情,你应该早...
继续阅读 »

我们公司人事小刘负责考勤统计。发完考勤表之后,有个员工找到他,说出勤少统计了一天。

小刘一听,感觉自己有被指控的风险。

他立刻严厉起来:“每天都来公司,不一定就算全勤。没打卡我是不统计的”。

最后小刘一查,发现是自己统计错了。

小刘反而更加强势了:“这种事情,你应该早点跟我反馈,而且多催着我确认。你自己的事情都不上心,扣个钱啥的只能自己兜着”

这就是明显的不愿意吃亏,即使自己错了,也不愿意让自己置于弱势。

你的反应,决定别人怎么对你。这种连言语的亏都不吃的人,并不会让别人敬畏,反而会让人厌恶,进而影响沟通

我还有一个同事老王。他是一个职场老人,性格嘻嘻哈哈,业务能力也很强。

以前同事小赵和老王合作的时候,小赵宁愿经两层人传话给老王,也不愿意和他直接沟通。

我当时感觉小赵不善于沟通。

后来,当我和老王合作的时候,才体会到小赵的痛苦。

因为,老王是一个什么亏都不吃的人,谁来找他理论,他就怼谁。

你告诉他有疏漏,他会极力掩盖问题,并且怒怼你愚昧无知。

就算你告诉他,说他家着火了。他首先说没有。你一指那不是烧着的吗?他回复,你懂个屁,你知道我几套房吗?我说的是我另一个家没着火。

有不少人,从不吃亏,无论什么情况,都不会让自己处于弱势。

这类人喜欢大呼小叫,你不小心踩他脚了,他会大喊:践踏我的尊严,和你拼了!

心理学讲,愤怒源于恐惧,因为他想逃避当前不利的局面

人总会遇到各种不公的待遇,或误会,或委屈。

遇到争议时,最好需要确认一下,排除自己的问题。

如果自己没错,那么比较好的做法就是:“我认为你说得不合理,首先……其次……最后……”。

不盲目服软,也不得理不饶人,全程平心静气,有理有据。这种人绝对人格魅力爆棚,让人敬佩。

最后,有时候过度强硬也是一种策略,可以很好地过滤和震慑一些不重要的事物。

作者:TF男孩
来源:juejin.cn/post/7196678344573173816

收起阅读 »

我竟然完美地用js实现默认的文本框粘贴事件

web
前言:本文实际是用js移动控制光标的位置!解决了网上没有可靠教程的现状废话连篇默认情况对一个文本框粘贴,应该会有这样的功能:粘贴文本后,光标不会回到所有文本的最后位置,而是在粘贴的文本之后将选中的文字替换成粘贴的文本但是由于需求,我们需要拦截粘贴的事件,对剪贴...
继续阅读 »

前言:本文实际是用js移动控制光标的位置!解决了网上没有可靠教程的现状

废话连篇

默认情况对一个文本框粘贴,应该会有这样的功能:

  1. 粘贴文本后,光标不会回到所有文本的最后位置,而是在粘贴的文本之后

  2. 将选中的文字替换成粘贴的文本

但是由于需求,我们需要拦截粘贴的事件,对剪贴板的文字进行过滤,这时候粘贴的功能都得自己实现了,而一旦自己实现,上面2个功能就不见了,我们就需要还原它。

面对这样的需求,我们肯定要控制移动光标,可是现在的网上环境真的是惨,千篇一律的没用代码...于是我就发表了这篇文章。

先上代码

    <textarea id="text" style="width: 996px; height: 423px;"></textarea>
   <script>
       // 监听输入框粘贴事件
       document.getElementById('text').addEventListener('paste', function (e) {
           e.preventDefault();
           let clipboardData = e.clipboardData.getData('text');
           // 这里写你对剪贴板的私货
           let tc = document.querySelector("#text");
           tc.focus();
           const start = (tc.value.substring(0,tc.selectionStart)+clipboardData).length;
           if(tc.selectionStart != tc.selectionEnd){
               tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionEnd)
          }else{
               tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionStart);
          }
           
           // 重新设置光标位置
           tc.selectionEnd =tc.selectionStart = start
      });
   </script>

怎么理解上述两个功能?
第一个解释:
比如说现在文本框有:

染念真的很生气

如果我们现在在真的后面粘贴不要,变成

染念真的不要很生气|

拦截后的光标是在生气后面,但是我们经常使用发现,光标应该出现在不要的后面吧!
就像这样:

染念真的不要|很生气

第2个解释:

染念真的不要很生气

我们全选真的的同时粘贴求你,拦截后会变成

染念真的求你不要很生气|

但默认应该是:

染念求你|不要很生气

代码分析

针对第2个问题,我们应该先要获取默认的光标位置在何处,tc.selectionStart是获取光标开始位置,tc.selectionEnd是获取光标结束位置。
为什么这里我写了一个判断呢?因为默认时候,我们没有选中一块区域,就是把光标人为移动到某个位置(读到这里,光标在位置后面,现在人为移动到就是前面,这个例子可以理解不?),这个时候两个值是相等的。

233|333
^--^
1--4
tc.selectionEnd=4,tc.selectionStart = 4

如果相等,说明就是简单的定位,tc.value = tc.value.substring(0,tc.selectionStart)+clipboardData+tc.value.substring(tc.selectionStart);,tc.value.substring(0,tc.selectionStart)获取光标前的内容,tc.value.substring(tc.selectionStart)是光标后的内容。
如果不相等,说明我们选中了一个区域(光标选中一块区域说明我们选中了一个区域),代码只需要在最后获取光标后的内容这的索引改成tc.selectionEnd

|233333|
^------^
1------7
tc.selectionEnd=7,tc.selectionStart = 1

在获取光标位置之前,我们应该先使用tc.focus();聚焦,使得光标回到文本框的默认位置(最后),这样才能获得位置。
针对第1个问题,我们就要把光标移动到粘贴的文本之后,我们需要计算位置。
获得这个位置,一定要在tc.value重新赋值之前,因为这样的索引都没有改动。
const start = (tc.value.substring(0,tc.selectionStart)+clipboardData).length;这个代码和上面解释重复,很简单,我就不解释了。
最后处理完了,重新设置光标位置,tc.selectionEnd =tc.selectionStart = start,一定让selectionEnd和selectionStart相同,不然选中一个区域了。

如果我们在value重新赋值之后获取(tc.value.substr(0,tc.selectionStart)+clipboardData).length,大家注意到没,我们操作的是tc.value,value已经变了,这里的重新定位光标开始已经没有任何意义了!

作者:染念
来源:dyedd.cn/943.html

收起阅读 »

闭包用多了会造成内存泄露 ?

web
闭包,是JS中的一大难点;网上有很多关于闭包会造成内存泄露的描述,说闭包会使其中的变量的值始终保持在内存中,一般都不太推荐使用闭包而项目中确实有很多使用闭包的场景,比如函数的节流与防抖那么闭包用多了,会造成内存泄露吗?场景思考以下案例: A 页面引入了一个 d...
继续阅读 »

闭包,是JS中的一大难点;网上有很多关于闭包会造成内存泄露的描述,说闭包会使其中的变量的值始终保持在内存中,一般都不太推荐使用闭包

而项目中确实有很多使用闭包的场景,比如函数的节流与防抖

那么闭包用多了,会造成内存泄露吗?

场景思考

以下案例: A 页面引入了一个 debounce 防抖函数,跳转到 B 页面后,该防抖函数中闭包所占的内存会被 gc 回收吗?

该案例中,通过变异版的防抖函数来演示闭包的内存回收,此函数中引用了一个内存很大的对象 info(42M的内存),便于明显地对比内存的前后变化

注:可以使用 Chrome 的 Memory 工具查看页面的内存大小:


场景步骤:

1) util.js 中定义了 debounce 防抖函数

// util.js`
let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
export const debounce = (fn, time) => {
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

2) A 页面中引入并使用该防抖函数

import { debounce } from './util';
mounted() {
   this.debounceFn = debounce(() => {
     console.log('1');
  }, 1000)
}
  • 抓取 A 页面内存: 57.1M


3) 从 A 页面跳转到 B 页面,B 页面中没有引入该 debounce 函数

问题: 从 A 跳转到 B 后,该函数所占的内存会被释放掉吗?

  • 此时,抓取 B 页面内存: 58.1M


  • 刷新 B 页面,该页面的原始内存为: 16.1M


结论: 前后对比发现,从 A 跳转到 B 后,B 页面内存增大了 42M,证明该防抖函数所占的内存没有被释放掉,即造成了内存泄露

为什么会这样呢? 按理说跳转 B 页面后,A 页面的组件都被销毁掉了,那么 A 页面所占的内存应该都被释放掉了啊😕

我们继续对比测试

4) 如果把 info 对象放到 debounce 函数内部,从 A 跳转到 B 后,该防抖函数所占的内存会被释放掉吗?

// util.js`
export const debounce = (fn, time) => {
let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

按照步骤 4 的操作,重新从 A 跳转到 B 后,B 页面抓取内存为16.1M,证明该函数所占的内存被释放掉了

为什么只是改变了 info 的位置,会引起内存的前后变化?

要搞懂这个问题,需要理解闭包的内存回收机制

闭包简介

闭包:一个函数内部有外部变量的引用,比如函数嵌套函数时,内层函数引用了外层函数作用域下的变量,就形成了闭包。最常见的场景为:函数作为一个函数的参数,或函数作为一个函数的返回值时

闭包示例:

function fn() {
let num = 1;
return function f1() {
  console.log(num);
};
}
let a = fn();
a();

上面代码中,a 引用了 fn 函数返回的 f1 函数,f1 函数中引入了内部变量 num,导致变量 num 滞留在内存中

打断点调试一下


展开函数 f 的 Scope(作用域的意思)选项,会发现有 Local 局部作用域、Closure 闭包、Global 全局作用域等值,展开 Closure,会发现该闭包被访问的变量是 num,包含 num 的函数为 fn

总结来说,函数 f 的作用域中,访问到了fn 函数中的 num 这个局部变量,从而形成了闭包

所以,如果真正理解好闭包,需要先了解闭包的内存引用,并且要先搞明白这几个知识点:

  • 函数作用域链

  • 执行上下文

  • 变量对象、活动对象

函数的内存表示

先从最简单的代码入手,看下变量是如何在内存中定义的

let a = '小马哥'

这样一段代码,在内存里表示如下


在全局环境 window 下,定义了一个变量 a,并给 a 赋值了一个字符串,箭头表示引用

再定义一个函数

let a = '小马哥'
function fn() {
 let num = 1
}

内存结构如下:


特别注意的是,fn 函数中有一个 [[scopes]] 属性,表示该函数的作用域链,该函数作用域指向全局作用域(浏览器环境就是 window),函数的作用域是理解闭包的关键点之一

请谨记:函数的作用域链是在创建时就确定了,JS 引擎会创建函数时,在该对象上添加一个名叫作用域链的属性,该属性包含着当前函数的作用域以及父作用域,一直到全局作用域

函数在执行时,JS 引擎会创建执行上下文,该执行上下文会包含函数的作用域链(上图中红色的线),其次包含函数内部定义的变量、参数等。在执行时,会首先查找当前作用域下的变量,如果找不到,就会沿着作用域链中查找,一直到全局作用域

垃圾回收机制浅析

现在各大浏览器通常用采用的垃圾回收有两种方法:标记清除、引用计数

这里重点介绍 "引用计数"(reference counting),JS 引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放


上图中,左下角的两个值,没有任何引用,所以可以释放

如果一个值不再需要了,引用数却不为0,垃圾回收机制无法释放这块内存,从而导致内存泄漏

判断一个对象是否会被垃圾回收的标准: 从全局对象 window 开始,顺着引用表能找到的都不是内存垃圾,不会被回收掉。只有那些找不到的对象才是内存垃圾,才会在适当的时机被 gc 回收

分析内存泄露的原因

回到最开始的场景,当 info 在 debounce 函数外部时,为什么会造成内存泄露?

进行断点调试


展开 debounce 函数的 Scope选项,发现有两个 Closure 闭包对象,第一个 Closure 中包含了 info 对象,第二个 Closure 闭包对象,属于 util.js 这个模块

内存结构如下:


当从 A 页面切换到 B 页面时,A 页面被销毁,只是销毁了 debounce 函数当前的作用域,但是 util.js 这个模块的闭包却没有被销毁,从 window 对象上沿着引用表依然可以查找到 info 对象,最终造成了内存泄露


当 info 在 debounce 函数内部时,进行断点调试


其内存结构如下:


当从 A 页面切换到 B 页面时,A 页面被销毁,同时会销毁 debounce 函数当前的作用域,从 window 对象上沿着引用表查找不到 info 对象,info 对象会被 gc 回收


闭包内存的释放方式

1、手动释放(需要避免的情况)

如果将闭包引用的变量定义在模块中,这种会造成内存泄露,需要手动释放,如下所示,其他模块需要调用 clearInfo 方法,来释放 info 对象

可以说这种闭包的写法是错误的 (不推荐), 因为开发者需要非常小心,否则稍有不慎就会造成内存泄露,我们总是希望可以通过 gc 自动回收,避免人为干涉

let info = {
 arr: new Array(10 * 1024 * 1024).fill(1),
 timer: null
};
export const debounce = (fn, time) => {
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};
export const clearInfo = () => {
 info = null;
};

2、自动释放(大多数的场景)

闭包引用的变量定义在函数中,这样随着外部引用的销毁,该闭包就会被 gc 自动回收 (推荐),无需人工干涉

export const debounce = (fn, time) => {
 let info = {
   arr: new Array(10 * 1024 * 1024).fill(1),
   timer: null
};
 return function (...args) {
   info.timer && clearTimeout(info.timer);
   info.timer = setTimeout(() => {
     fn.apply(this, args);
  }, time);
};
};

结论

综上所述,项目中大量使用闭包,并不会造成内存泄漏,除非是错误的写法

绝大多数情况,只要引用闭包的函数被正常销毁,闭包所占的内存都会被 gc 自动回收。特别是现在 SPA 项目的盛行,用户在切换页面时,老页面的组件会被框架自动清理,所以我们可以放心大胆的使用闭包,无需多虑

理解了闭包的内存回收机制,才算彻底搞懂了闭包。以上关于闭包的理解,如果有误,欢迎指正 🌹

参考链接:
浏览器是怎么看闭包的。
JavaScript 内存泄漏教程
JavaScript闭包(内存泄漏、溢出以及内存回收),超直白解析

作者:海阔_天空
来源:juejin.cn/post/7196636673694285882

收起阅读 »

人保寿险要求全员背诵董事长罗熹金句,央媒痛批其“谄媚”

最近,中国人保寿险公司品牌宣传部门,给人保集团董事长罗熹惹出舆情。去年12月2日,人保寿险官方公众号“中国人保寿险”发布文章《首季峰启动会上,罗熹董事长这些金句值得收藏!》。文中提到,“直达人心,催人奋进 董事长金句来了!”近日,该公司又专门发《通知》,要求公...
继续阅读 »

最近,中国人保寿险公司品牌宣传部门,给人保集团董事长罗熹惹出舆情。

去年12月2日,人保寿险官方公众号“中国人保寿险”发布文章《首季峰启动会上,罗熹董事长这些金句值得收藏!》。文中提到,“直达人心,催人奋进 董事长金句来了!”


近日,该公司又专门发《通知》,要求公司总、省、地市、县支各级机构全体干部员工,“学习、熟读、并背诵董事长在首季峰启动会上传达的金句集锦。”


《通知》称,总公司各部门主要负责人、各级机构一把手要充分发挥示范带动作用,带头讲金句、用金句,通过集中学习、个人自学、背诵打卡等多种方式,确保全体内勤人员将金句内容牢记于心、付诸于行……

《通知》中还提到,要在今年2月10日前,完成全员闭卷通关及考试,并对考试成绩进行汇总。“纸质试卷需妥善保管,以备检查。”

今年1月29日,“中国人保寿险”公众号推送了《以考促学,一套题带你牢记“首季峰”金句》的文章。文内的多道填空题,均是罗董事长的致辞“金句”。


该事件引发关注后,人保寿险删除了上述这两篇公众号文章。

此外,有媒体报道称,人保寿险2月4日深夜发布的一份内部邮件显示,1月30日下发的文件《关于开展“学习罗董金句,激扬奋进力量”学习活动的通知》已被废止。

被卷入“学金句”旋涡的罗熹履新人保集团董事长时间并不长。去年11月21日,银保监会发布消息称,核准了罗熹新职务。

公开资料显示,罗熹出生于1960年12月,毕业于中国人民银行研究生部,经济学硕士学位,高级经济师,1977年8月参加工作以来,曾在多家银行、保险公司工作。

2月6日,有自媒体称,自己因2月4日发布《如此谄媚领导?一央企发文要求全体员工学习、熟读、背诵董事长“金句”》文章,收到人保寿险的撤稿函。


有网友评论称,作为央企的人保寿险公司,发文要求全体员工学习、熟读、背诵董事长罗熹的“金句”,而且还有相应学习活动的测试试题,如此形式主义是否合适?是否有“谄媚领导”之嫌?

中新社旗下的中新经纬2月6日晚间发表评论称,这种“金句学习”的企业文化,更像是一种职场“洗脑”,加深了外界对寿险行业的不良观感。

“强制员工背诵董事长金句,看似是让员工领会管理者的经营思路和企业发展战略,实则是下属谄媚上级之举,容易使企业员工陷入盲目个人崇拜。”评论称,作为一家企业的领导者,更应该时刻保持清醒的头脑,及时制止下属的变相吹捧。

评论指出,对保险公司来说,与其将董事长金句背会,不如将每一张一张保单做好,每一笔业务做到位,这样方能赢得更多客户信任。

作者:一见财经
来源:zhuanlan.zhihu.com/p/604080917

收起阅读 »

一杯咖啡的时间☕️,搞懂 API 和 RESTful API!

☀️ 前言API和RESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的...
继续阅读 »

☀️ 前言

  • APIRESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足一些最基本的要求。

  • 如果你还不了解什么是API或你没有了解RESTful API,你可以选择花5分钟时间看下去,我会最通俗易懂的帮你普及这一切。

❓ 什么是 API

  • 举个简单的例子你就会明白:

    • 早在2000年我们还在用小灵通的时代,网上购票已经慢慢兴起,但是绝大部分人出行还是通过电话查询航班来去选择购票,我们首先需要打电话到附近的站台根据时间询问航班或车次,得到结果后再去到对应站台进行购票。


  • 随着时代的飞速发展和智能手机的普及,各种旅游App也映入眼帘,大家也学会了如何在App上进行购票

  • 这时候我们买票就没有以前那么麻烦了,在App输入你的起点终点后,会展现所有符合条件的车次,航班,不仅仅只有时间、座位,还有航空公司、预计时间等等等等详细信息一目了然,你只需要根据你的需求购买即可。


  • 连接是一件很棒的事情,在我们现在的生活中,我们可以很轻松的通过App进行购物、阅读、直播,我们以前所未有的方式和世界与人们相连接。

  • 那这些是怎么做到的?为什么一个App能够这么便利?这些资料为什么会可以从A到达B,为什么我们只需要动动手指就可以达到这一切?

  • 而这个桥梁,这个互联网世界的无名英雄就是APIAPI,全名 Application Programming Interface (应用程式界面),简单来说,是品牌开发的一种接口,让第三方可以额外开发、应用在自身的产品上的系统沟通界面。

  • 简单来说,你可以把它比喻成古人的鸽子,通过飞鸽传书来传达你的需求,而接收方再把回应通过鸽子传达给你。

  • 再说回上面举的例子。

    • 旧时代我们需要知道航班的信息,我们就需要一个信差,而这个电话员就是这个信差,也就是我们说的 API,他传达你的要求到系统,而站台就是这个系统,比如告诉它查询明天飞往广州的飞机,那么他就会得出结果,由电话员传递给你。

    • 而现在我们需要购买机票等,只需要通过购票系统选择日期,城市,舱位等,他会从不同的航空公司网站汇集资料,而汇集资料的手段就是通过API和航空公司互动。

  • 我们现在知道是API让我们使用这些旅游 App,那么这个道理也一样适用于生活中任何应用程序、资料和装置之间的互动,都有各自的API进行连接。

❓ 什么是 RESTful API

  • 在互联网并没有完全流行的初期,移动端也没有那么盛行,页面请求和并发量也不高,那时候人们对接口(API)的要求没那么高。

  • 当初的 web 应用程序主要是在服务器端实现的,因此需要使用复杂的协议来操作和传输数据。然而,随着移动端设备的普及,需要在移动端也能够访问 web 应用程序,而客户端和服务端就需要接口进行通信,但接口的规范性就又成了一个问题。


  • 所以一套简化开发、结构清晰、符合标准、易于理解、易于扩展让大部分人都能够理解接受的接口风格就显得越来越重要,而RESTful风格的接口(RESTful API)刚好有以上特点,就逐渐应运而生。

REST

  • REST,全名 Representational State Transfer(表现层状态转移),他是一种设计风格,一种软件架构风格,而不是标准,只是提供了一组设计原则和约束条件

RESTful

  • RESTful 只是转为形容詞,就像那么 RESTful API 就是满足 REST风格的,以此规范设计的 API

RESTful API

  • 我们常见的 API 一般都长这样子:


  • RESTful 风格的 API 却长这样子:


🔘 六大原则

  • Roy FieldingHTTP 协议的主要设计者之一,他在论文中阐述了 REST 架构的概念并给出了 REST 架构的六个限制条件,也就是六大原则

Uniform Interface(统一接口)
  • 就像我们上面两幅图看到的 API,这是最直观的特征,是 REST 架构的核心,统一的接口对于 RESTful 服务非常重要。客户端只需要关注实现接口就可以,接口的可读性加强,使用人员方便调用

  • RESTful API通过 URL定位资源,并通过

    HTTP方法操作该资源,对资源的操作包括获取、创建、修改和删除,这些操作正好对应 HTTP 协议提供的GETPOSTPUTDELETE方法。

    • GET:获取资源信息。

    • POST:创建一个新资源。

    • PUT:更新已有的资源。

    • DELETE:删除已有的资源。

  • 在一个完全遵循 RESTful 的团队里,后端只需要告诉前端 /users 这个 API,前端就应该知道:

    • 获取所有用户:GET /users

    • 获取特定用户:GET /users/{id}

    • 创建用户:POST /users

    • 更新用户:PUT /users/{id}

    • 删除用户:DELETE /users/{id}

  • API 数量非常多,系统非常复杂时,RESTful 的好处会越来越明显。理解系统时,可以直接围绕一系列资源来理解和记忆。

Client-Server(客户端和服务端分离)
  • 它意味着客户端和服务器是独立的、可以分离的

  • 客户端是负责请求和处理数据的组件,服务器是负责存储数据处理请求的组件。

  • 这两个组件之间通过一组约定来协作,以便客户端能够获取所需的数据。

Statelessness(无状态)
  • 它指的是每个请求都是独立的没有前后关系。服务器不保存客户端的状态信息,并且每个请求都必须包含所有所需的信息。

  • 这样做的好处是可以使每个请求变得简单容易理解处理,并且可以更容易地扩展和维护

  • 例如,假设你在登录一个网站,你需要在登录界面输入用户名和密码通过接口获取到了 token 。接下来的所有请求都需要携带上这个 token 而不是系统在第一次登录成功之后记录了你的状态。

Cacheability(可缓存)
  • 客户端和服务端可以协商缓存内容,通过设置 HTTP 状态码,服务器可以告诉客户端这个数据是否可以被缓存。

  • 例如,一个 HTTP 响应头中包含一个 Cache-Control 字段,用于告诉客户端该数据可以缓存多长时间。这样可以提高数据传输的效率,从而降低网络带宽的开销,加速数据的访问速度。

Layered System(分层)
  • 客户端不应该关心请求经过了多少中间层,只需要关心请求的结果。

  • 架构的系统可以分为多个层次,每一层独立完成自己的任务。这样的架构结构使得系统更容易维护,并且允许独立替换不同层次。

  • 例如,数据库存储层可以独立于其他层,在不影响其他层的情况下进行替换或扩展。

Code on Demand(可选的代码请求)
  • 它提倡服务器可以将客户端代码下载到客户端并执行。这样,客户端可以根据服务器发送的代码来扩展它的功能。

  • 这个限制可以使客户端代码变得更加灵活,并且可以通过服务器提供的代码来解决问题,而不必再等待下一个版本。

  • Code on Demand 是可选的,但它可以使 RESTful API 变得更加灵活和可扩展。

🔥 RESTful API 设计规范

  • 说了这么多的理论,那我们该如何去设计一个最简单 RESTful 风格的 API 呢?

HTTP 方法
  • HTTP 设计了很多动词,来标识不同的操作,不同的HTTP请求方法有各自的含义,就像上面所展示的,RESTful API 应该使用 HTTP 方法(如 GET、POST、PUTDELETE)来描述操作。

版本控制
URL 明确标识资源
  • API 应该使用简洁明了的 URL 来标识资源,并且在同一个资源上使用不同的 HTTP 方法来执行不同的操作。

  • 这样的设计使得客户端在无需任何额外信息的情况下就可以找到所需的资源。

  • 不规范的的 URL,形式千奇百怪,不同的开发者还需要了解文档才能调用。

  • 规范后的 RESTful 风格的 URL,形式固定,可读性强,根据名词和 HTTP 动词就可以操作这些资源。


  • 给大家一个小 tips,如果你遇到了实在想不到的 URL ,你可以参考github开放平台 ,这里面有很多很规范的 URL 设计。

HTTP 状态码
  • HTTP状态码是 RESTful API设计的重要一环,是表示 API请求的状态,用于告知客户端是否成功请求并处理数据。常用的 HTTP状态码有:

    • 200 OK:请求成功,表示获得了请求的数据

    • 201 Created:请求成功,表示创建了一个新的资源

    • 204 No Content:请求成功,表示操作成功,但没有返回数据

    • 400 Bad Request:请求失败,表示请求格式不正确或缺少必要参数

    • 401 Unauthorized:请求失败,表示认证失败或缺少授权

    • 403 Forbidden:请求失败,表示没有访问权限

    • 404 Not Found:请求失败,表示请求的资源不存在

    • 500 Internal Server Error:请求失败,表示服务器内部错误

统一返回数据格式
  • 常用的返回数据格式有 JSONXML

  • JSON 是现在比较流行的数据格式,它是简单、轻量、易于解析,并且它有很好的可读性。

  • XML 也是一种常用的数据格式,它的优点是比较灵活,并且支持各种数据类型。

合格美观的 API 文档
  • 项目开发离不开前后端分离,离不开 API,当然也就离不开 API 文档,但是文档的编写又成为程序员觉得麻烦事之一,甚至我还看到有公司的的 API 文档是用 Word 文档手敲的。

  • 市面上有很多可以管理 API 的软件,每个人都有自己的选择,我给大家推荐一款 API 管理神器 Apifox,可以一键生成 API 文档。

  • 不需要你过多的操作,只需要你在可视化的页面添加你的 API 即可生成,现在也支持了多种导航模式亮暗色模式顶部自定义 Icon 、文案可跳转到你的官网等地址


  • 对于独立开发者和团队来说都是一大利好福音,本文就不做过多介绍,感兴趣的可以去试试~

👋🏻 写在最后

  • 总的来说 RESTful 风格的 API 固然很好很规范,但大多数互联网公司并没有按照或者完全按照其规则来设计,因为 REST 是一种风格,而不是一种约束或规则,过于理想的 RESTful API 会付出太多的成本。

  • 如果您正在考虑使用 RESTful API,请确保它符合您的业务需求。例如,如果您的项目需要实现复杂的数据交互,您可能需要考虑使用其他 API 设计方法。

  • 因此,请确保在选择 API 设计方法时充分考虑您的业务需求。此外,您还需要确保 RESTful API 与您的系统架构和技术栈相兼容。通过这些考虑,您可以确保 RESTful API 的正确使用,并且可以实现更高效和可靠的 API

  • 长期来看,API 设计也不只是后端的工作,而是一个产品团队在产品设计上的协调工作,应该整个团队参与。

  • 这次简单分享了 APIRESTful API,在实际运用中,并不是一定要使用这种规范,但是有 RESTful 标准可以参考,是十分有必要的,希望对大家有帮助。

作者:快跑啊小卢_
来源:juejin.cn/post/7196570893152616506

收起阅读 »

我当面试官的经历总结

背景工作之余,负责过公司前端岗位的一些技术面试,一直在想,能不能对这个经历做一个总结,遂有了这篇文章。文章主要内容如下:我的面试风格面试者——简历格式与内容面试者——简历亮点面试者——准备面试面试官——面试前准备面试官——面试中面试官——面试结果评价总结我的面...
继续阅读 »

背景

工作之余,负责过公司前端岗位的一些技术面试,一直在想,能不能对这个经历做一个总结,遂有了这篇文章。

文章主要内容如下:

  1. 我的面试风格

  2. 面试者——简历格式与内容

  3. 面试者——简历亮点

  4. 面试者——准备面试

  5. 面试官——面试前准备

  6. 面试官——面试中

  7. 面试官——面试结果评价

  8. 总结

我的面试风格

我非常讨厌问一些稀奇古怪的问题,也不喜欢遇到任何面试者,都准备几个相同的技术问题。我的面试风格可以总结为以下几点:

  1. 根据简历内容,提炼和简历深度关联的技术场景

  2. 将提炼的技术场景分解成问题,可以是一个问题,也可以是多个问题,可以困难,也可以容易

  3. 和面试者进行友好交流,感受面试者的各种反馈,尊重面试者

  4. 面试是一个互相学习的过程

以上总结可以用如下思维导图概括:


面试者——简历格式与内容

我们看一张两个简历的对比图,如下所示:


上图中的两个简历,代表了大多数人的简历样子。大家可以自行感觉下,哪一个简历更好些。

我对简历格式与内容,有如下两点看法:

  1. 我更喜欢图中简历 2 的格式,但简历格式不会影响我的面试评价

  2. 简历内容是核心,我会根据简历内容来决定要不要面试和如何面试

所以对于面试者来说,一定要写好简历内容。

面试者——简历亮点

究竟什么样的内容算是亮点呢?对此,我罗列了简历亮点的思维导图,如下图所示:


简洁阐述下简历亮点思维导图:

  1. 技术丰富:有深度,比如你在 node 方面做了 ssr 、微服务和一些底层工具等;有广度,比如你实践过 pch5 、小程序、桌面端、ssrnode 、微前端、低代码等

  2. 项目:比如你深度参与或者主导低代码平台项目建设,该项目非常复杂,在建设过程中,做了很多技术等方面的提升和创新,产生了很好的效果

  3. 博客/开源:比如你写的博客文章质量高,有自己独特和深入的见解;你在开源方面做了很多贡献,提了一些好的 pr ,有自己的开源作品

  4. 公司知名:这个好理解,比如你在头部互联网,独角兽等公司工作过

  5. 其他:学历和工作年限,算是门槛,合适也是亮点

面试者要善于把自己的亮点展示在简历上,这对于应聘心怡公司来说,是非常重要的事情。

面试者——准备面试

面试者在准备面试阶段,应当做好以下 5 点:

  1. 写好简历内容,这个是重中之重

  2. 整理好自我介绍,控制好时间,做到言简意赅,把重点、亮点突出

  3. 确定好回答面试官提问的基本方式,保持统一的回答方式

  4. 根据简历内容,自己对自己做一次面试,或者找朋友模拟面试官,面试自己

  5. 找出不足,进行优化

面试者可以对写好的简历,用思维导图等工具,对内容进行分解,如下图所示:


在分解完成后,我们将相同点进行归纳,然后对多次提及,重复提及,着重提及的归纳进行重点复习和梳理。

这里用上图举 2 个归纳例子说明下:

我的技术栈中提及 pnpm yarn , 其涉及到的知识点,有以下:

  1. 包管理器选型, npm yarn pnpm 三者的区别

  2. monorepo 设计

我的重要功能提及商详页,其涉及到的知识点,有以下:

  1. 性能优化

  2. wap 端的常见问题,如 1px 问题、滚动穿透、响应式、终端适配

做好面试准备,会让你在面试过程中,胸有成竹,运筹帷幄。

面试官——面试前准备

主要有以下四个步骤:

  1. 看简历:作为面试官,在面试前,要认真看面试者的简历,这是对面试者的尊重

  2. 找亮点:这块参考上文提到的面试者亮点

  3. 定场景:根据简历内容和亮点,确定深度关联的技术场景

  4. 提问题:将确定的技术场景分解成问题,可以是一个问题,也可以是多个问题,可以困难,也可以容易

我认为面试前准备是面试官最重要的流程,这个做好了,剩下的就很容易做了。

面试官——面试中

整个过程的主线如下:

  1. 官方开头:比如打招呼、面试者自我介绍

  2. 重点过程:这个过程主要有两个事情:

第一个事情:按照上文 面试前准备 的内容来和面试者进行沟通交流,衡量面试者的回答和所写简历内容两者之间的联系 第二个事情:对于有疑惑的联系,要二次验证,这个举个例子

比如面试者简历上写,自研组件库。我问他按需加载是怎么实现的,他的回答会有下面两种情况

第一种情况:回答的很好,这个时候我会再讨论一个按需加载相关的小问题,如果回答还是很流畅。那很好,这个就是面试亮点

第二种情况:回答的很差,那我会怀疑自研组件库是不是他用心做的事情。因为他有可能是 fork 一个开源组件库,然后改改,然后就没然后了。这个时候,我倾向于直接和他沟通,比如问他在自研组件库上花了多少时间,是不是随便搞的。在回答很差的前置条件下,面试者大都会说实情。这样我就能掌握正确的信息,避免误解。

  1. 官方结尾:上家辞职原因、为什么选择来我司、定居情况、回答面试者提的各种问题

面试官——面试结果评价

结果无非就是失败和成功,绝大多数的面试结果评价都是客观公正的,剩下的少数都是一些特殊情况,遇到这种,那就是运气不好了。

总结

以上是我作为面试官经历的一次总结,虽然面试次数不多,但依然值得我为此写一个总结,这是一份宝贵的面经。

作者:码上有你
来源:juejin.cn/post/7195770700107399228

收起阅读 »

字节前端监控实践

简述Slardar 前端监控自18年底开始建设以来,从仅仅作为 Sentry 的替代,经历了一系列迭代和发展,目前做为一个监控解决方案,已经应用到抖音、西瓜、今日头条等众多业务线。据21年下旬统计,Slardar 前端监控(Web + Hybrd) 工作日晚间...
继续阅读 »

简述

Slardar 前端监控自18年底开始建设以来,从仅仅作为 Sentry 的替代,经历了一系列迭代和发展,目前做为一个监控解决方案,已经应用到抖音、西瓜、今日头条等众多业务线。

据21年下旬统计,Slardar 前端监控(Web + Hybrd) 工作日晚间峰值 qps 300w+,日均处理数据超过千亿条。

image.png 本文,我将针对在这一系列的发展过程中,字节内部监控设计和迭代遇到的落地细节设计问题,管中窥豹,向大家介绍我们团队所思考和使用的解决方案。

他们主要围绕着前端监控体系建设的一些关键问题展开。也许大家对他们的原理早已身经百战见得多了,不过当实际要落地实现的时候,还是有许多细节可以再进一步琢磨的。

image.png

如何做好 JS 异常监控

JS 异常监控本质并不复杂,浏览器早已提供了全局捕获异常的方案。

window.addEventListenr('error', (err) => {
   report(normalize(err))
});

window.addEventListenr('unhandledrejection', (rejection) => {
   report(normalize(rejection))
});
复制代码

但捕获到错误仅仅只是相关工作的第一步。在我看来,JS 异常监控的目标是:

  1. 开发者迅速感知到 JS 异常发生

  2. 通过监控平台迅速定位问题

  3. 开发者能够高效的处理问题,并统计,追踪问题的处理进度

在异常捕获之外,还包括堆栈的反解与聚合,处理人分配和报警这几个方面。

image.png

堆栈反解: Sourcemap

大家都知道 Script 脚本在浏览器中都是以明文传输并执行,现代前端开发方案为了节省体积,减少网络请求数,不暴露业务逻辑,或从另一种语言编译成 JS。都会选择将代码进行混淆和压缩。在优化性能,提升用户体验的同时,也为异常的处理带来了麻烦。

在本地开发时,我们通常可以清楚的看到报错的源代码堆栈,从而快速定位到原始报错位置。而线上的代码经过压缩,可读性已经变得非常糟糕,上报的堆栈很难对应到原始的代码中。 Sourcemap 正是用来解决这个问题的。

简单来说,Sourcemap 维护了混淆后的代码行列到原代码行列的映射关系,我们输入混淆后的行列号,就能够获得对应的原始代码的行列号,结合源代码文件便可定位到真实的报错位置。

Sourcemap 的解析和反解析过程涉及到 VLQ 编码,它是一种将代码映射关系进一步压缩为类base64编码的优化手段。

image.png 在实际应用中,我们可以把它直接当成黑盒,因为业界已经为我们提供了方便的解析工具。下面是一个利用 mozila 的 sourcemap 库进行反解的例子。

image.png

以上代码执行后通常会得到这样的结果,实际上我们的在线反解服务也就是这样实现的

image.png

当然,我们不可能在每次异常发生后,才去生成 sourcemap,在本地或上传到线上进行反解。这样的效率太低,定位问题也太慢。另一个方案是利用 sourcemappingURL 来制定sourcemap 存放的位置,但这样等于将页面逻辑直接暴露给了网站使用者。对于具有一定规模和保密性的项目,这肯定是不能接受的。

 //# sourceMappingURL=http://example.com/path/hello.js.map
复制代码

为了解决这个问题,一个自然的方案便是利用各种打包插件或二进制工具,在构建过程中将生成的 sourcemap 直接上传到后端。Sentry 就提供了类似的工具,而字节内部也是使用相似的方案。

image.png

image.png 通过如上方案,我们能够让用户在发版构建时就可以完成 sourcemap 的上传工作,而异常发生后,错误可以自动完成解析。不需要用户再操心反解相关的工作了。

堆栈聚合策略

当代码被成功反解后,用户已经可以看到这一条错误的线上和原始代码了,但接下来遇到的问题则是,如果我们只是上报一条存一条,并且给用户展示一条错误,那么在平台侧,我们的异常错误列表会被大量的重复上报占满,

对于错误类型进行统计,后续的异常分配操作都无法正常进行。

在这种情况下,我们需要对堆栈进行分组和聚合。也就是,将具有相同特征的错误上报,归类为统一种异常,并且只对用户暴露这种聚合后的异常。

image.png

堆栈怎么聚合效果才好呢?我们首先可以观察我们的JS异常所携带的信息,一个异常通常包括以下部分

img

  • name: 异常的 Type,例如 TypeError, SyntaxError, DOMError

  • Message:异常的相关信息,通常是异常原因,例如 a.b is not defined.

  • Stack (非标准)异常的上下文堆栈信息,通常为字符串

那么聚合的方案自然就出来了,利用某种方式,将 error 相关的信息利用提取为 fingerprint,每一次上报如果能够获得相同的 fingerprint,它们就可以归为一类。那么问题进一步细化为:如何利用 Error 来保证 fingerprint 的区分尽量准确呢?

image.png

如果跟随标准,我们只能利用 name + message 作为聚合依据。但在实践过程中,我们发现这是远远不够的。如上所示,可以看到这两个文件发生的位置是完全不同的,来自于不同的代码段,但由于我们只按照 name + message 聚合。它们被错误聚合到了一起,这样可能造成我们修复了其中一个错误后。误以为相关的所有异常都被解决。

因此,很明显我们需要利用非标准的 error.stack 的信息来帮我们解决问题了。在这里我们参考了 Sentry 的堆栈聚合策略:

image.png 除了常规的 name, message, 我们将反解后的 stacktrace 进一步拆分为一系列的 Frame,每一个 Frame 内我们重点关注其调用函数名,调用文件名以及当前执行的代码行(图中的context_line)。

image.png

Sentry 将每一个拆分出的部分都称为一个 GroupingComponent,当堆栈反解完毕后,我们首先自上而下的递归检测,并自下而上的生成一个个嵌套的 GroupingComponent。最后,在顶层调用 GroupingComponent.getHash() 方法, 得到一个最终的哈希值,这就是我们最终求得的 fingerprint。

image.png 相较于message+name, 利用 stacktrace 能够更细致的提取堆栈特征,规避了不同文件下触发相同 message 的问题。因此获得的聚合效果也更优秀。这个策略目前在字节内部的工作效果良好,基本上能够做到精确的区分各类异常而不会造成混淆和错误聚合。

处理人自动分配策略

异常已经成功定位后,如果我们可以直接将异常分配给这行代码的书写者或提交者,可以进一步提升问题解决的效率,这就是处理人自动分配所关心的,通常来说,分配处理人依赖 git blame 来实现。

image.png

一般的团队或公司都会使用 Gitlab / Github 作为代码的远端仓库。而这些平台都提供了丰富的 open-api 协助用户进行blame,

我们很自然的会联想到,当通过 sourcemap 解出原始堆栈路径后,如果可以结合调用 open-api,获得这段代码所在文件的blame历史, 我们就有机会直接在线上确定某一行的可能的 author / commitor 究竟是谁。从而将这个异常直接分配给他。

思路出来了,那么实际怎么落地呢?

我们需要几个信息

  1. 线上报错的项目对应的源代码仓库名,如 toutiao-fe/slardar

  2. 线上报错的代码发生的版本,以及与此版本关联的 git commit 信息,为什么需要这些信息呢?

默认用来 blame 的文件都是最新版本,但线上跑的不一定是最新版本的代码。不同版本的代码可能发生行的变动,从而影响实际代码的行号。如果我们无法将线上版本和用来 blame 的文件划分在统一范围内,则很有可能自动定位失败。

因此,我们必须找到一种方法,确定当前 blame 的文件和线上报错的文件处于同一版本。并且可以直接通过版本定位到关联的源代码 commit 起止位置。这样的操作在 Sentry 的官方工具 Sentry-Cli 中亦有提供。字节内部同样使用了这种方案。

通过 相关的 二进制工具,在代码发布前的脚本中提供当前将要发布的项目的版本和关联的代码仓库信息。同时在数据采集侧也携带相同的版本,线上异常发生后,我们就可以通过线上报错的版本找到原始文件对应的版本,从而精确定位到需要哪个时期的文件了。

image.png

异常报警

当异常已经成功反解和聚合后,当用户访问监控平台,已经可以观察并处理相关的错误,不过到目前为止,异常的发生还无法触及开发者,问题的解决依然依靠“走查”行为。这样的方案对严重的线上问题依然是不够用,因此我们还需要主动通知用户的手段,这就是异常报警。

在字节内部,报警可以分为宏观报警,即针对错误指标的数量/比率的报警,以及微观报警,即针对新增异常的报警。

image.png

宏观报警

宏观报警是数量/比率报警, 它只是统计某一类指标是否超出了限定的阈值,而不关心它具体是什么。因此默认情况下它并不会告诉你报警的原因。只有通过归因维度或者下文会提到的 微观(新增异常)报警 才能够知晓引发报警的具体原因

关于宏观报警,我们有几个关键概念

  • 第一是样本量,用户数阈值: 在配置比率指标时。如果上报量过低,可能会造成比率的严重波动,例如错误率 > 20%, 的报警下,如果 JS 错误数从 0 涨到 1, 那就是比率上涨到无限大从而造成没有意义的误报。如果不希望被少量波动干扰,我们设置了针对错误上报量和用户数的最低阈值,例如只有当错误影响用户数 > 5 时,才针对错误率变化报警。

image.png

  • 第二是归因维度: 对于数量,比率报警,仅仅获得一个异常指标值是没什么意义的,因为我们无法快速的定位问题是由什么因素引发的,因此我们提供了归因维度配置。例如,通过对 JS 异常报警配置错误信息归因,我们可以在报警时获得引发当前报警的 top3 关键错误和增长最快的 top3 错误信息。

  • 第三是时间窗口,报警运行频率: 如上文所说,报警是数量,比率报警,而数量,比率一定有一个统计范围,这个就是通过 时间窗口 来确定的。而报警并不是时时刻刻盯着我们的业务数据的,可以理解为利用一个定时器来定期检查 时间窗口 内的数据是否超出了我们定义的阈值。而这个定时器的间隔时间,就是 报警运行频率。通过这种方式,我们可以做到类实时的监测异常数据的变化,但又没有带来过大的资源开销。

微观报警(新增异常)

相较于在意宏观数量变化的报警,新增异常在意每一个具体问题,只要此问题是此前没有出现过的,就会主动通知用户。

同时,宏观报警是针对数据的定时查找,存在运行频率和时间窗口的限制,实时性有限。微观报警是主动推送的,具有更高的实时性。

微观报警适用于发版,灰度等对新问题极其关注,并且不方便在此时专门配置相关数量报警的阶段。

如何判断“新增”?

我们在 异常自动分配章节讲到了,我们的业务代码都是可以关联一个版本概念的。实际上版本不仅和源代码有关,也可以关联到某一类错误上。

在这里我们同样也可以基于版本视角判断“新增错误”。

对于新增异常的判断,针对两种不同场景做了区分

  • 对于指定版本、最新版本的新增异常报警,我们会分析该报警的 fingerprint 是否为该版本代码中首次出现。

  • 而对于全体版本,我们则将"首次”的范围增加了时间限制,因为对于某个错误,如果在长期没有出现后又突然出现,他本身还是具有通知的意义的,如果不进行时间限制,这个错误就不会通知到用户,可能会出现信息遗漏的情况。

如何做好性能监控?

如果说异常处理是前端监控体系60分的分界线,那么性能度量则是监控体系能否达到90分的关键。一个响应迟钝,点哪儿卡哪儿的页面,不会比点开到处都是报错的页面更加吸引人。页面的卡顿可能会直接带来用户访问量的下降,进而影响背后承载的服务收入。因此,监控页面性能并提升页面性能也是非常重要的。针对性能监控,我们主要关注指标选取品质度量 、瓶颈定位三个关键问题。

指标选取

指标选取依然不是我们今天文章分享的重点。网上关于 RUM 指标,Navigation 指标的介绍和采集方式已经足够清晰。通常分为两个思路:

  1. RUM (真实用户指标) -> 可以通过 Web Vitals (github.com/GoogleChrom…*

  2. 页面加载指标 -> NavigationTiming (ResourceTiming + DOM Processing + Load) 可以通过 MDN 相关介绍学习。这里都不多赘述。

瓶颈定位

收集到指标只是问题的第一步,接下来的关键问题便是,我们应该如何找出影响性能问题的根因,并且针对性的进行修复呢?

慢会话 + 性能时序分析

如果你对“数据洞察/可观测性”这个概念有所了解,那么你应该对 Kibana 或 Datadog 这类产品有所耳闻。在 kibana 或 Datadog 中都能够针对每一条上传的日志进行详细的追溯。和详细的上下文进行关联,让用户的体验可被观测,通过多种筛选找到需要用户的数据。

在字节前端的内部建设中,我们参考了这类数据洞察平台的消费思路。设计了数据探索能力。通过数据探索,我们可以针对用户上报的任意维度,对一类日志进行过滤,而不只是获得被聚合过的列表信息数据。这样的消费方式有什么好处呢?

  1. 我们可以直接定位到一条具体日志,找到一个现实的 data point 来分析问题

  2. 这种视图的状态是易于保存的,我们可以将找到的数据日志通过链接发送给其他人,其他用户可以直接还原现场。

对于性能瓶颈,在数据探索中,可以轻松通过针对某一类 PV 上报所关联的性能指标进行数值筛选。也可以按照某个固定时段进行筛选,从而直接获得响应的慢会话。这样的优势在于我们不用预先设定一个“慢会话阈值”,需要哪个范围的数据完全由我们自己说了算。例如,通过对 FCP > 3000ms 进行筛选,我们就能够获得一系列 FCP > 3s 的 PV 日志现场。

image.png

在每次 PV 上报后,我们会为数据采集的 SDK 设置一个全局状态,比如 view_id, 只要没有发生新的页面切换,当前的 view_id 就会保持不变。

而后续的一系列请求,异常,静态资源上报就可以通过 view_id 进行后端的时序串联。形成一张资源加载瀑布图。在瀑布图中我们可以观察到各类性能指标和静态资源加载,网络请求的关系。从而检测出是否是因为某些不必要的或者过大的资源,请求导致的页面性能瓶颈。这样的瀑布图都是一个个真实的用户上报形成的,相较于统计值产生的甘特图,更能帮助我们解决实际问题。

image.png

结合Longtask + 用户行为分析

通过指标过滤慢会话,并且结合性能时序瀑布图分析,我们能够判断出当前页面中是否存在由于网络或过大资源因素导致的页面加载迟缓问题

但页面的卡顿不一定全是由网络因素造成的。一个最简单的例子。当我在页面的 head 中插入一段非常耗时的同步脚本(例如 while N 次),则引发页面卡顿的原因就来自于代码执行而非资源加载。

image.png

针对这种情况,浏览器同样提供了 Longtask API 供我们收集这类占据主线程时间过长的任务。
同样的,我们将这类信息一并收集,并通过上文提到的 view_id 串联到一次页面访问中。用户就可以观察到某个性能指标是否受到了繁重的主线程加载的影响。若有,则可利用类似 lighthouse 的合成监控方案集中检查对应页面中是否存在相关逻辑了。

image.png

受限于浏览器所收集到的信息,目前的 longtask 我们仅仅只能获得它的执行时间相关信息。而无法像开发者面板中的 performance 工具一样准确获取这段逻辑是由那段代码引发的。如果我们能够在一定程度上收集到longtask触发的上下文,则可定位到具体的慢操作来源。

此外,页面的卡顿不一定仅仅发生在页面加载阶段,有时页面的卡顿会来自于页面的一次交互,如点击,滚动等等。这类行为造成的卡顿,仅仅依靠 RUM / navigation 指标是无法定位的。如果我们能够通过某种方式(在PPT中已经说明),对操作行为计时。并将操作计时范围内触发的请求,静态资源和longtask上报以同样的瀑布图方式收敛到一起。则可以进一步定位页面的“慢操作”,从而提升页面交互体验。

如下图所示,我们可以检查到,点击 slardar_web 这个按钮 / 标签带来了一系列的请求和 longtask,如果这次交互带来了一定的交互卡顿。我们便可以集中修复触发这个点击事件所涉及的逻辑来提升页面性能表现。

image.png

品质度量

当我们采集到一个性能指标后,针对这样一个数字,我们能做什么?

我们需要结论:好还是不好?

实际上我们通常是以单页面为维度来判定指标的,以整站视角来评判性能的优劣的置信度会受到诸多因素影响,比如一个站点中包含轻量的登陆页和功能丰富的中后台,两者的性能要求和用户的容忍度是不一致的,在实际状况下两者的绝对性能表现也是不一致的。而简单平均只会让我们观察不到重点,页面存在的问题数据也可能被其他的页面拉平。

其次,指标只是冷冰冰的数据,而数据想要发挥作用,一定需要参照系。比如,我仅仅提供 FMP = 4000ms,并不能说明这个页面的性能就一定需要重点关注,对于逻辑较重的PC页面,如数据平台,在线游戏等场景,它可能是符合业务要求的。而一个 FMP = 2000ms的页面则性能也不一定好,对于已经做了 SSR 等优化的回流页。这可能远远达不到我们的预期。

一个放之四海而皆准的指标定义是不现实的。不同的业务场景有不同的性能基准要求。我们可以把他们转化为具体的指标基准线。

通过对于现阶段线上指标的分布,我们可以可以自由定义当前站点场景下针对某个指标,怎样的数据是好的,怎样的数据是差的。

基准线应用后,我们便可以在具体的性能数据产出后,直观的观察到,在什么阶段,某些指标的表现是不佳的,并且可以集中针对这段时间的性能数据日志进行排查。

image.png

一个页面总是有多个性能指标的,现在我们已经知道了单个性能指标的优劣情况,如何整体的判断整个页面,乃至整个站点的性能状况,落实到消费侧则是,我们如何给一个页面的性能指标评分?

如果有关注过 lighthouse 的同学应该对这张图不陌生。

image.png lighthouse 通过 google 采集到的大量线上页面的性能数据,针对每一个性能指标,通过对数正态分布将其指标值转化成 百分制分数。再通过给予每个指标一定的权重(随着 lighthouse 版本更迭), 计算出该页面性能模块的一个“整体分数”。在即将上线的“品质度量”能力中,我们针对 RUM 指标,异常指标,以及资源加载指标均采取了类似的方案。

image.png

我们通常可以给页面的整体性能分数再制定一个基准分数,当上文所述的性能得分超过分数线,才认为该页面的性能水平是“达标”的。而整站整体的达标水平,则可以利用整站达标的子页面数/全站页面数来计算,也就是达标率,通过达标率,我们可以非常直观的迅速找到需要优化的性能页面,让不熟悉相关技术的运营,产品同学也可以定期巡检相关页面的品质状况。

如何做好请求 / 静态资源监控?

除了 JS 异常和页面的性能表现以外,页面能否正常的响应用户的操作,信息能否正确的展示,也和 api 请求,静态资源息息相关。表现为 SLA,接口响应速度等指标。现在主流的监控方案通常是采用手动 hook相关 api 和利用 resource timing 来采集相关信息的。

手动打点通常用于请求耗时兜底以及记录请求状态和请求响应相关信息。

  1. 对于 XHR 请求: 通过 hook XHR 的 open 和 send 方法, 获取请求的参数,在 onreadystatechange 事件触发时打点记录请求耗时。

    1.  // 记录 method
      hookObjectProperty(XMLHttpRequest.prototype, 'open', hookXHROpen);
      // hook onreadystateChange,调用前后打点计算
      hookObjectProperty(XMLHttpRequest.prototype, 'send', hookXHRSend);
      复制代码
  2. 对于fetch请求,则通过 hook Fetch 实现

    1. hookObjectProperty(global, 'fetch', hookFetch)
      复制代码
  • 第二种则是 resourceTiming 采集方案

    1. 静态资源上报:

      1. pageLoad 前:通过 performance.getEntriesByType 获取 resource 信息

      2. pageLoad后:通过 PerformanceObserver 监控 entryType 为 resource 的资源

  • const callback = (val, i, arr, ob) => // ... 略
    const observer = new PerformanceObserver((list, ob) => {
       if (list.getEntries) {
         list.getEntries().forEach((val, i, arr) => callback(val, i, arr, ob))
      } else {
         onFail && onFail()
      }
       // ...
    });

    observer.observe({ type: 'resource', buffered: false })
    复制代码

手动打点的优势在于无关兼容性,采集方便,而 Resource timing 则更精准,并且其记录中可以避开额外的事件队列处理耗时

如何理解和使用 resource timing 数据?

我们现在知道 ResourceTiming 是更能够反映实际资源加载状况的相关指标,而在工作中,我们经常遇到前端请求上报时间极长而后端对应接口日志却表现正常的情况。这通常就可能是由使用单纯的打点方案计算了太多非服务端因素导致的。影响一个请求在前端表现的因素除了服务端耗时以外,还包括网络,前端代码执行排队等因素。我们如何从 ResourceTiming 中分离出这些因素,从而更好的对齐后端口径呢?

第一种是 Chrome 方案(阿里的 ARMS 也采用的是这种方案):

它通过将线上采集的 ResoruceTiming 和 chrome timing 面板的指标进行类比还原出一个近似的各部分耗时值。他的简单计算方式如图所示。
img img
不过 chrome 实际计算 timing 的方式不明,这种近似的方式不一定能够和 chrome 的面板数据对的上,可能会被用户质疑数据不一致。

第二种则是标准方案: 规范划分阶段,这种划分是符合 W3C 规范的格式,其优势便在于其通用性好,且数据一定是符合要求的而不是 chrome 方案那种“近似计算”。不过它的缺陷是阶段划分还是有点太粗了,比如用户无法判断出浏览器排队耗时,也无法完全区分网络下载和下载完成后的资源加载阶段。只是简单的划分成了 Request / Response 阶段,给用户理解和分析带来了一定成本

在字节内部,我们是以标准方案为主,chrome方案为辅的,用户可以针对自己喜好的那种统计方式来对齐指标。通常来说,和服务端对齐耗时阶段可以利用标准方案的request阶段减去severtiming中的cdn,网关部分耗时来确定。

image.png

接下来我们再谈谈采集 SDK 的设计。

SDK 如何降低侵入,减少用户性能损耗?体积控制和灵活使用可以兼得吗?

常需要尽早执行,其资源加载通常也会造成一定的性能影响。更大的资源加载可能会导致更慢的 Load,LCP,TTI 时间,影响用户体验。

image.png

为了进一步优化页面加载性能,我们采用了 JS Snippets 来实现异步加载 + 预收集。

  1. 异步加载主要逻辑

首先,如果通过 JS 代码创建 script 脚本并追加到页面中,新增的 script 脚本默认会携带 async 属性,这意味着这这部分代码将通过async方式延迟加载。下载阶段不会阻塞用户的页面加载逻辑。从而一定程度的提升用户的首屏性能表现。

image.png

  1. 预收集

试想一下我们通过 npm 或者 cdn 的方式直接引入监控代码,script必须置于业务逻辑最前端,这是因为若异常先于监控代码加载发生,当监控代码就位时,是没有办法捕获到历史上曾经发生过的异常的。但将script置于前端将不可避免的对用户页面造成一定阻塞,且用户的页面可能会因此受到我们监控 sdk 服务可用性的影响。

为了解决这个问题,我们可以同步的加载一段精简的代码,在其中启动 addEventListener 来采集先于监控主要逻辑发生的错误。并存储到一个全局队列中,这样,当监控代码就位,我们只需要读取全局队列中的缓存数据并上报,就不会出现漏报的情况了。

image.png

更进一步:事件驱动与插件化

方案1. 2在大部分情况下都已经比较够用了,但对于字节的某些特殊场景却还不够。由于字节存在大量的移动端页面,且这些页面对性能极为敏感。因而对于第三方库的首包体积要求非常苛刻,同时,也不希望第三方代码的执行占据主线程太长时间。

此外,公司内也有部分业务场景特殊,如 node 场景,小程序场景,electron,如果针对每一种场景,都完全重新开发一套新的监控 SDK,有很大的人力重复开发的损耗。

如果我们能够将 SDK 的框架逻辑做成平台无关的,而各个数据监控,收集方案都只是以插件形式存在,那么这个 SDK 完全是可插拔的,类似 Sentry 所使用的 integration 方案。用户甚至可以完全不使用任何官方插件,而是通过自己实现相关采集方案,来做到项目的定制化。

关于框架设计可以参见下图
img img img

  1. 我们把整个监控 SDK 看作一条流水线(Client),接受的是用户配置(config)(通过 ConfigManager),收集和产出的是具体事件(Event, 通过 Plugins)。流水线是平台无关的,它不关心处理的事件是什么,也不关心事件是从哪来的。它其实是将这一系列的组件交互都抽象为 client 上的事件,从而使得数据采集器能够介入数据流转的每个阶段
    Client 通过 builder 包装事件后,转运给 Sender 负责批处理,Sender 最终调用 Transporter 上报。Transporter 是平台强相关的,例如 Web 使用 xhr 或 fetch,node 则使用 request 等。 同时,我们利用生命周期的概念设置了一系列的钩子,可以让用户可以在适当阶段处理流水线上的事件。例如利用 beforeSend 钩子去修改即将被发送的上报内容等。

imgimg

当整体的框架结构设计完后,我们就可以把视角放到插件上了。由于我们将框架设置为平台无关的,它本身只是个数据流,有点像是一个精简版的 Rx.js。而应用在各个平台上,我们只需要根据各个平台的特性设计其对应的采集或数据处理插件。

插件方案某种意义上实现了 IOC,用户不需要关心事件怎么处理,传入的参数是哪里来的,只需要利用传入的参数去获取配置,启动自己的插件等。如下这段JS采集器代码,开发插件时,我们只需要关心插件自身相关的逻辑,并且利用传入 client 约定的相关属性和方法工作就可以了。不需要关心 client 是怎么来的,也不用关心 client 什么时候去执行它。

image.png

当我们写完了插件之后,它要怎么才能被应用在数据采集和处理中呢?为了达成降低首包大小的目标,我们将插件分为同步和异步两种加载方式。

  1. 可以预收集的监控代码都不需要出现在首包中,以异步插件方式接入

  2. 无法做到预收集的监控代码以同步形式和首包打在一起,在源码中将client传入,尽早启动,保证功能稳定。

image.png 3. 异步插件采用约定式加载,用户在使用层面是完全无感的。我们通过主包加载时向在全局初始化注册表和注册方法,在读取用户配置后,拉取远端插件加载并利用全局注册方法获取插件实例,最后传入我们的 client 实现代码执行。

image.png

经过插件化和一系列 SDK 的体积改造后,我们的sdk 首包体积降低到了从63kb 降低到了 34 kb。

image.png

总结

本文主要从 JS 异常监控,性能监控和请求,静态资源监控几个细节点讲述了 Slardar 在前端监控方向所面临关键问题的探索和实践,希望能够对大家在前端监控领域或者将来的工作中产生帮助。其实前端监控还有许多方面可以深挖,例如如何利用拨测,线下实验室数据采集来进一步追溯问题,如何捕获白屏等类崩溃异常,如何结合研发流程来实现用户无感知的接入等等。

作者:字节架构前端
来源:https://juejin.cn/post/7195496297150709821

收起阅读 »

一个炫酷的头像悬停效果

web
本文翻译自 A Fancy Hover Effect For Your Avatar,略有删改,有兴趣可以看看原文。你知道当一个人的头像从一个圆圈或洞里伸出来时的那种效果吗?本文将使用一种很简洁的方式实现该悬停效果,可以用在你的头像交互上面。看到了吗?我们将制...
继续阅读 »

本文翻译自 A Fancy Hover Effect For Your Avatar,略有删改,有兴趣可以看看原文。

你知道当一个人的头像从一个圆圈或洞里伸出来时的那种效果吗?本文将使用一种很简洁的方式实现该悬停效果,可以用在你的头像交互上面。


看到了吗?我们将制作一个缩放动画,其中头像部分似乎从它所在的圆圈中钻出来了。是不是很酷呢?接下来让我们一起一步一步地构建这个动画交互效果。

HTML:只需要一个元素

是的,只需要一个img图片标签即可,本次练习的挑战性部分是使用尽可能少的代码。如果你已经关注我一段时间了,你应该习惯了。我努力寻找能够用最小、最易维护的代码实现的CSS解决方案。

<img src="" alt="">

首先我们需要一个带有透明背景的正方形图像文件,以下是本次案例使用的图像。


在开始CSS之前,让我们先分析一下效果。悬停时图像会变大,所以我们肯定会在这里使用transform:scale。头像后面有一个圆圈,径向渐变应该可以达到这个效果。最后我们需要一种在圆圈底部创建边框的方法,该边框将不受整体放大的影响且是在视觉顶层。

放大效果

放大的效果,增加transform:scale,这个比较简单。

img:hover {
 transform: scale(1.35);
}

上面说过背景是一个径向渐变。我们创建一个径向渐变,但是两个颜色之间不要有过渡效果,这样使得它看起来像我们画了一个有实线边框的圆。

img {
 --b: 5px; /* border width */

 background:
   radial-gradient(
     circle closest-side,
     #ECD078 calc(99% - var(--b)),
     #C02942 calc(100% - var(--b)) 99%,
     #0000
  );
}

注意CSS变量,--b,在这里它表示“边框”的宽度,实际上只是用于定义径向渐变红色部分的位置。


下一步是在悬停时调整渐变大小,随着图像的放大,圆需要保持大小不变。由于我们正在应用scale变换,因此实际上需要减小圆圈的大小,否则它会随着化身的大小而增大。

让我们首先定义一个CSS变量--f,它定义了“比例因子”,并使用它来设置圆的大小。我使用1作为默认值,因为这是图像和圆的初始比例,我们从圆转换。

现在我们必须将背景定位在圆的中心,并确保它占据整个高度。我喜欢把所有东西都直接简写在 background 属性,代码如下:

background: radial-gradient() 50% / calc(100% / var(--f)) 100% no-repeat;

背景放置在中心( 50%),宽度等于calc(100%/var(--f)),高度等于100%。

当 --f 等于 1 时是我们最初的比例。同时,渐变占据容器的整个宽度。当我们增加 --f,元素的大小会增长但是渐变的大小将减小。


越来越接近了!我们在顶部添加了溢出效果,但我们仍然需要隐藏图像的底部,这样它看起来就像是跳出了圆圈,而不是整体浮在圆圈前面。这是整个过程中比较复杂的部分,也是我们接下来要做的。

下边框

第一次尝试使用border-bottom属性,但无法找到一种方法来匹配边框的大小与圆的大小。如图所示,相信你能看出来无法实现我们想要的效果:

实际的解决方案是使用outline属性。不是borderoutline可以让我们创造出很酷的悬停效果。结合 outline-offset 偏移量,我们就可以实现所需要的效果。

其核心是在图像上设置一个outline轮廓并调整其偏移量以创建下边框。偏移量将取决于比例因子,与渐变大小相同。outline-offset 偏移量看起来相对比较复杂,这里对计算方式进行了精简,有兴趣的可以看看原文。

img {
 --s: 280px; /* image size */
 --b: 5px; /* border thickness */
 --c: #C02942; /* border color */
 --f: 1; /* initial scale */
 
 border-radius: 0 0 999px 999px;
 outline: var(--b) solid var(--c);
 outline-offset: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

}

因为我们需要一个圆形的底部边框,所以在底部添加了一个边框圆角,使轮廓与渐变的弯曲程度相匹配。


现在我们需要找到如何从轮廓中删除顶部,也就是上图中挡住头像的那根线。换句话说,我们只需要图像的底部轮廓。首先,在顶部添加空白和填充,以帮助避免顶部头像的重叠,这通过增加padding即可实现:

padding-top: calc(var(--s)/5)

这里还有一个注意点,需要添加 content-box 值添加到 background

background:
 radial-gradient(
   circle closest-side,
   #ECD078 calc(99% - var(--b)),
   var(--c) calc(100% - var(--b)) 99%,
   #0000
) 50%/calc(100%/var(--f)) 100% no-repeat content-box;

这样做是因为我们添加了padding填充,并且我们只希望将背景设置为内容框,因此我们必须显式地定义出来。

CSS mask

到了最后一部分!我们要做的就是藏起一些碎片。为此,我们将依赖于 CSS mask 属性,当然还有渐变。

下面的图说明了我们需要隐藏的内容或需要显示的内容,以便更加准确。左图是我们目前拥有的,右图是我们想要的。绿色部分说明了我们必须应用于原始图像以获得最终结果的遮罩内容。


我们可以识别mask的两个部分:

  • 底部的圆形部分,与我们用来创建化身后面的圆的径向渐变具有相同的维度和曲率

  • 顶部的矩形,覆盖轮廓内部的区域。请注意轮廓是如何位于顶部的绿色区域之外的-这是最重要的部分,因为它允许剪切轮廓,以便只有底部部分可见

最终的完整css如下,对有重复的代码进行抽离,如--g,--o:

img {
 --s: 280px; /* image size */
 --b: 5px; /* border thickness */
 --c: #C02942; /* border color */
 --f: 1; /* initial scale */

 --_g: 50% / calc(100% / var(--f)) 100% no-repeat content-box;
 --_o: calc((1 / var(--f) - 1) * var(--s) / 2 - var(--b));

 width: var(--s);
 aspect-ratio: 1;
 padding-top: calc(var(--s)/5);
 cursor: pointer;
 border-radius: 0 0 999px 999px;
 outline: var(--b) solid var(--c);
 outline-offset: var(--_o);
 background:
   radial-gradient(
     circle closest-side,
     #ECD078 calc(99% - var(--b)),
     var(--c) calc(100% - var(--b)) 99%,
     #0000) var(--_g);
 mask:
   linear-gradient(#000 0 0) no-repeat
   50% calc(-1 * var(--_o)) / calc(100% / var(--f) - 2 * var(--b)) 50%,
   radial-gradient(
     circle closest-side,
     #000 99%,
     #0000) var(--_g);
 transform: scale(var(--f));
 transition: .5s;
}
img:hover {
 --f: 1.35; /* hover scale */
}

下面的一个演示,直观的说明mask的使用区域。中间的框说明了由两个渐变组成的遮罩层。把它想象成左边图像的可见部分,你就会得到右边的最终结果:

最后

搞定!我们不仅完成了一个流畅的悬停动画,而且只用了一个<img>元素和不到20行的CSS技巧!如果我们允许自己使用更多的HTML,我们能简化CSS吗?当然可以。但我们是来学习CSS新技巧的!这是一个很好的练习,可以探索CSS渐变、遮罩、outline属性的行为、转换以及其他许多内容。

在线效果

实例里面是流行的CSS开发人员的照片。有兴趣的同学可以展示一下自己的头像效果。

看完本文如果觉得有用,记得点个赞支持,收藏起来说不定哪天就用上啦~

作者:南城FE
来源:juejin.cn/post/7196747356796518460

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)2.3 内部通讯协议完善当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在...
继续阅读 »

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

2.3 内部通讯协议完善

当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在内部完成。

override fun send(requestRequest?): Response? {
   //获取服务对象id
   val serviceId = request?.serviceId
   val methodName = request?.methodName
   val params = request?.params
   // 反序列化拿到具体的参数类型
   val neededParams = parseParameters(params)
   val method = Registry.instance.findMethod(serviceIdmethodNameneededParams)
   Log.e("TAG""method $method")
   Log.e("TAG""neededParams $neededParams")
   when (request?.type) {

       REQUEST_TYPE.GET_INSTANCE.ordinal -> {
           //==========执行静态方法
           try {
               var instanceAny? = null
               instance = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(null)
              } else {
                   method?.invoke(nullneededParams)
              }
               if (instance == null) {
                   return Response("instance == null"-101)
              }
               //存储实例对象
               Registry.instance.setServiceInstance(serviceId ?""instance)
               return Response(null200)
          } catch (eException) {
               return Response("${e.message}"-102)
          }
      }
       REQUEST_TYPE.INVOKE_METHOD.ordinal -> {
           //==============执行普通方法
           val instance = Registry.instance.getServiceInstance(serviceId)
           if (instance == null) {
               return Response("instance == null "-103)
          }
           //方法执行返回的结果
           return try {

               val result = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(instance)
              } else {
                   method?.invoke(instanceneededParams)
              }
               Response(gson.toJson(result), 200)
          } catch (eException) {
               Response("${e.message}"-104)
          }

      }
  }

   return null
}

当客户端发起请求时,会将请求的参数封装到Request中,在服务端接收到请求后,就会解析这些参数,变成Method执行时需要传入的参数。

private fun parseParameters(paramsArray<Parameters>?): Array<Any?>? {
   if (params == null || params.isEmpty()) {
       return null
  }
   val objects = arrayOfNulls<Any>(params.size)
   params.forEachIndexed { indexparameters ->
       objects[index=
           gson.fromJson(parameters.valueClass.forName(parameters.className))
  }
   return objects
}

例如用户中心调用setUserInfo方法时,需要传入一个User实体类,如下所示:

UserManager().setUserInfo(User("ming",25))

那么在调用这个方法的时候,首先会把这个实体类转成一个JSON字符串,例如:

{
  "name":"ming",
  "age":25
}

为啥要”多此一举“呢?其实这种处理方式是最快速直接的,转成json字符串之后,能够最大限度地降低数据传输的大小,等到服务端处理这个方法的时候,再把Request中的params反json转成User对象即可。

fun findMethod(serviceIdString?methodNameString?neededParamsArray<Any?>?): Method? {
   //获取服务
   val serviceClazz = serviceMaps[serviceId?return null
   //获取方法集合
   val methods = methodsMap[serviceClazz?return null
   return methods[rebuildParamsFunc(methodNameneededParams)]
}

private fun rebuildParamsFunc(methodNameString?paramsArray<Any?>?): String {

   val stringBuffer = StringBuffer()
   stringBuffer.append(methodName).append("(")

   if (params == null || params.isEmpty()) {
       stringBuffer.append(")")
       return stringBuffer.toString()
  }
   stringBuffer.append(params[0]?.javaClass?.name)
   for (index in 1 until params.size) {
       stringBuffer.append(",").append(params[index]?.javaClass?.name)
  }
   stringBuffer.append(")")
   return stringBuffer.toString()
}

那么在查找注册方法的时候就简单多了,直接抽丝剥茧一层一层取到最终的Method。在拿到Method之后,这里是有2种处理方式,一种是通过静态单例的形式拿到实例对象,并保存在服务端;另一种就是执行普通方法,因为在反射的时候需要拿到类的实例对象才能调用,所以才在GET_INSTANCE的时候存一遍

3 客户端 - connect

在第二节中,我们已经完成了通讯协议的建设,最终一步就是客户端通过绑定服务,向服务端发起通信了。

3.1 bindService

/**
* 绑定服务
*
*/
fun connect(
   contextContext,
   pkgNameString,
   actionString = "",
   serviceClass<out IPCService>
) {
   val intent = Intent()
   if (pkgName.isEmpty()) {
       //同app内的不同进程
       intent.setClass(contextservice)
  } else {
       //不同APP之间进行通信
       intent.setPackage(pkgName)
       intent.setAction(action)
  }
   //绑定服务
   context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
}

inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

   override fun onServiceConnected(nameComponentName?serviceIBinder?) {
       val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
       binders[simpleService= mService
  }

   override fun onServiceDisconnected(nameComponentName?) {
       //断连之后,直接移除即可
       binders.remove(simpleService)
  }
}

对于绑定服务这块,相信伙伴们也很熟悉了,这个需要说一点的就是,在Android 5.0以后,启动服务不能只依赖action启动,还需要指定应用包名,否则就会报错。

在服务连接成功之后,即回调onServiceConnected方法的时候,需要拿到服务端的一个代理对象,即IIPCServiceInterface的实例对象,然后存储在binders集合中,key为绑定的服务类class对象,value就是对应的服务端的代理对象。

fun send(
   typeInt,
   serviceClass<out IPCService>,
   serviceIdString,
   methodNameString,
   paramsArray<Parameters>
): Response? {
   //创建请求
   val request = Request(typeserviceIdmethodNameparams)
   //发起请求
   return try {
       binders[service]?.send(request)
  } catch (eException) {
       null
  }
}

当拿到服务端的代理对象之后,就可以在客户端调用send方法向服务端发送消息。

class Channel {

   //====================================
   /**每个服务对应的Binder对象*/
   private val bindersConcurrentHashMap<Class<out IPCService>IIPCServiceInterface> by lazy {
       ConcurrentHashMap()
  }

   //====================================

   /**
    * 绑定服务
    *
    */
   fun connect(
       contextContext,
       pkgNameString,
       actionString = "",
       serviceClass<out IPCService>
  ) {
       val intent = Intent()
       if (pkgName.isEmpty()) {
           intent.setClass(contextservice)
      } else {
           intent.setPackage(pkgName)
           intent.setAction(action)
           intent.setClass(contextservice)
      }
       //绑定服务
       context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
  }

   inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

       override fun onServiceConnected(nameComponentName?serviceIBinder?) {
           val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
           binders[simpleService= mService
      }

       override fun onServiceDisconnected(nameComponentName?) {
           //断连之后,直接移除即可
           binders.remove(simpleService)
      }
  }


   fun send(
       typeInt,
       serviceClass<out IPCService>,
       serviceIdString,
       methodNameString,
       paramsArray<Parameters>
  ): Response? {
       //创建请求
       val request = Request(typeserviceIdmethodNameparams)
       //发起请求
       return try {
           binders[service]?.send(request)
      } catch (eException) {
           null
      }
  }


   companion object {
       private val instance by lazy {
           Channel()
      }

       /**
        * 获取单例对象
        */
       fun getDefault(): Channel {
           return instance
      }
  }
}

3.2 动态代理获取接口实例

回到1.2小节中,我们定义了一个IUserManager接口,通过前面我们定义的通信协议,只要我们获取了IUserManager的实例对象,那么就能够调用其中的任意普通方法,所以在客户端需要设置一个获取接口实例对象的方法。

fun <T> getInstanceWithName(
   serviceClass<out IPCService>,
   classTypeClass<T>,
   clazzClass<*>,
   methodNameString,
   paramsArray<Parameters>
): T? {
   
   //获取serviceId
   val serviceId = clazz.getAnnotation(ServiceId::class.java)

   val response = Channel.getDefault()
      .send(REQUEST.GET_INSTANCE.ordinalserviceserviceId.namemethodNameparams)
   Log.e("TAG""response $response")
   if (response != null && response.result) {
       //请求成功,返回接口实例对象
       return Proxy.newProxyInstance(
           classType.classLoader,
           arrayOf(classType),
           IPCInvocationHandler()
      ) as T
  }

   return null
}

当我们通过客户端发送一个获取单例的请求后,如果成功了,那么就直接返回这个接口的单例对象,这里直接使用动态代理的方式返回一个接口实例对象,那么后续执行这个接口的方法时,会直接走到IPCInvocationHandler的invoke方法中。

class IPCInvocationHandler(
   val serviceClass<out IPCService>,
   val serviceIdString?
) : InvocationHandler {

   private val gson = Gson()

   override fun invoke(proxyAny?methodMethod?argsArray<out Any>?): Any? {

       //执行客户端发送方法请求
       val response = Channel.getDefault()
          .send(
               REQUEST.INVOKE_METHOD.ordinal,
               service,
               serviceId,
               method?.name ?"",
               args
          )
       //拿到服务端返回的结果
       if (response != null && response.result) {
           //反序列化得到结果
           return gson.fromJson(response.valuemethod?.returnType)
      }


       return null
  }

}

因为服务端在拿到Method的返回结果时,将javabean转换为了json字符串,因此在IPCInvocationHandler中,当调用接口中方法获取结果之后,用Gson将json转换为javabean对象,那么就直接获取到了结果。

3.3 框架使用

服务端:

UserManager2.getDefault().setUserInfo(User("ming"25))
IPC.register(UserManager2::class.java)

同时在服务端需要注册一个IPCService的实例,这里用的是IPCService01

<service
   android:name=".UserService"
   android:enabled="true"
   android:exported="true" />
<service
   android:name="com.lay.ipc.service.IPCService01"
   android:enabled="true"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.GET_USER_INFO" />
   </intent-filter>
</service>

客户端:

调用connect方法,需要绑定服务端的服务,传入包名和action

IPC.connect(
   this,
   "com.lay.learn.asm",
   "android.intent.action.GET_USER_INFO",
   IPCService01::class.java
)

首先获取IUserManager的实例,注意这里要和服务端注册的UserManager2是同一个ServiceId,而且接口、javabean需要存放在与服务端一样的文件夹下

val userManager = IPC.getInstanceWithName(
   IPCService01::class.java,
   IUserManager::class.java,
   "getDefault",
   null
)
val info = userManager?.getUserInfo()

通过动态代理拿到接口的实例对象,只要调用接口中的方法,就会进入到InvocationHandler中的invoke方法,在这个方法中,通过查找服务端注册的方法名从而找到对应的Method,通过反射调用拿到UserManager中的方法返回值。

这样其实就通过5-6行代码,就完成了进程间通信,是不是比我们在使用AIDL的时候要方便地许多。

4 总结

如果我们面对下面这个类,如果这个类是个私有类,外部没法调用,想通过反射的方式调用其中某个方法。

@ServiceId(name = "UserManagerService")
public class UserManager2 implements IUserManager {

   private static UserManager2 userManager2 = new UserManager2();

   public static UserManager2 getDefault() {
       return userManager2;
  }

   private User user;

   @Nullable
   @Override
   public User getUserInfo() {
       return user;
  }

   @Override
   public void setUserInfo(@NonNull User user) {
       this.user = user;
  }

   @Override
   public int getUserId() {
       return 0;
  }

   @Override
   public void setUserId(int id) {

  }
}

那么我们可以这样做:

val method = UserManager2::class.java.getDeclaredMethod("getUserInfo")
method.isAccessible = true
method.invoke(this,params)

其实这个框架的原理就是上面这几行代码所能够完成的事;通过服务端注册的形式,将UserManager2中所有的方法Method收集起来;当另一个进程,也就是客户端想要调用其中某个方法的时候,通过方法名来获取到对应的Method,调用这个方法得到最终的返回值

作者:layz4android
来源:juejin.cn/post/7192465342159912997

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

如果在Android中想要实现进程间通信,有哪些方式呢?(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;那么本篇文章并不是说完全丢弃掉AIDL,它依然不失...
继续阅读 »

对于进程间通信,很多项目中可能根本没有涉及到多进程,很多公司的app可能就一个主进程,但是对于进程间通信,我们也是必须要了解的。

如果在Android中想要实现进程间通信,有哪些方式呢?

(1)发广播(sendBroadcast):e.g. 两个app之间需要通信,那么可以通过发送广播的形式进行通信,如果只想单点通信,可以指定包名。但是这种方式存在的弊端在于发送方无法判断接收方是否接收到了广播,类似于UDP的通信形式,而且存在丢数据的形式;

(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;

(3)AIDL通信:这种算是Android当中主流的进程间通信方案,通过Service + Binder的形式进行通信,具备实时性而且能够通过回调得知接收方是否收到数据,弊端在于需要管理维护aidl接口,如果不同业务方需要使用不同的aidl接口,维护的成本会越来越高。

那么本篇文章并不是说完全丢弃掉AIDL,它依然不失为一个很好的进程间通信的手段,只是我会封装一个适用于任意业务场景的IPC进程间通讯框架,这个也是我在自己的项目中使用到的,不需要维护很多的AIDL接口文件。

有需要源码的伙伴,可以去我的github首页获取 FastIPC源码地址分支:feature/v0.0.1-snapshot有帮助的话麻烦给点个star⭐️⭐️⭐️

1 服务端 - register

首先这里先说明一下,就是对于传统的AIDL使用方式,这里就不再过多介绍了,这部分还是比较简单的,有兴趣的伙伴们可以去前面的文章中查看,本文将着重介绍框架层面的逻辑。

那么IPC进程间通信,需要两个端:客户端和服务端。服务端会提供一个注册方法,例如客户端定义的一些服务,通过向服务端注册来做一个备份,当客户端调用服务端某个方法的时候来返回值。

object IPC {

   //==========================================

   /**
    * 服务端暴露的接口,用于注册服务使用
    */
   fun register(serviceClass<*>) {
       Registry.instance.register(service)
  }

}

其实在注册的时候,我们的目的肯定是能够方便地拿到某个服务,并且能够调用这个服务提供的方法,拿到我想要的值;所以在定义服务的时候,需要注意以下两点:

(1)需要定义一个与当前服务一一对应的serviceId,通过serviceId来获取服务的实例;

(2)每个服务当中定义的方法同样需要对应起来,以便拿到服务对象之后,通过反射调用其中的方法。

所以在注册的时候,需要从这两点入手。

1.1 定义服务唯一标识serviceId

@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceId(
   val nameString
)

一般来说,如果涉及到反射,最常用的就是通过注解给Class做标记,因为通过反射能够拿到类上标记的注解,就能够拿到对应的serviceId。

class Registry {

   //=======================================
   /**用于存储 serviceId 对应的服务 class对象*/
   private val serviceMapsConcurrentHashMap<StringClass<*>> by lazy {
       ConcurrentHashMap()
  }

   /**用于存储 服务中全部的方法*/
   private val methodsMapConcurrentHashMap<Class<*>ConcurrentHashMap<StringMethod>> by lazy {
       ConcurrentHashMap()
  }


   //=======================================

   /**
    * 服务端注册方法
    * @param service 服务class对象
    */
   fun register(serviceClass<*>) {

       // 获取serviceId与服务一一对应
       val serviceIdAnnotation = service.getAnnotation(ServiceId::class.java)
           ?throw IllegalArgumentException("只有标记@ServiceId的服务才能够被注册")
       //获取serviceId
       val name = serviceIdAnnotation.name
       serviceMaps[name= service
       //temp array
       val methodsConcurrentHashMap<StringMethod> = ConcurrentHashMap()
       // 获取服务当中的全部方法
       for (method in service.declaredMethods) {

           //这里需要注意,因为方法中存在重载方法,所以不能把方法名当做key,需要加上参数
           val buffer = StringBuffer()
           buffer.append(method.name).append("(")
           val params = method.parameterTypes
           if (params.size > 0) {
               buffer.append(params[0].name)
          }
           for (index in 1 until params.size) {
               buffer.append(",").append(params[index].name)
          }
           buffer.append(")")
           //保存
           methods[buffer.toString()] = method
      }
       //存入方法表
       methodsMap[service= methods
  }

   companion object {
       val instance by lazy { Registry() }
  }
}

通过上面的register方法,当传入定义的服务class对象的时候,首先获取到服务上标记的@ServiceId注解,注意这里如果要注册必须标记,否则直接抛异常;拿到serviceId之后,存入到serviceMaps中。

然后需要获取服务中的全部方法,因为考虑到重载方法的存在,所以不能单单以方法名作为key,而是需要把参数也加上,因此这里做了一个逻辑就是将方法名与参数名组合一个key,存入到方法表中。

这样注册任务就完成了,其实还是比较简单的,关键在于完成2个表:服务表和方法表的初始化以及数据存储功能

1.2 使用方式

@ServiceId("UserManagerService")
interface IUserManager {

   fun getUserInfo()User?
   fun setUserInfo(userUser)
   fun getUserId()Int
   fun setUserId(idInt)
}

假设项目中有一个用户信息管理的服务,这个服务用于给所有的App提供用户信息查询。

@ServiceId("UserManagerService")
class UserManager : IUserManager {

   private var userUser? = null
   private var userIdInt = 0

   override fun getUserInfo(): User? {
       return user
  }

   override fun setUserInfo(userUser) {
       this.user = user
  }

   override fun getUserId()Int {
       return userId
  }

   override fun setUserId(idInt) {
       this.userId = id
  }

}

用户中心可以注册这个服务,并且调用setUserInfo方法保存用户信息,那么其他App(客户端)连接这个服务之后,就可以调用getUserInfo这个方法,获取用户信息,从而完成进程间通信。

2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E