司想君

司想君 查看完整档案

青岛编辑北京交通大学  |  系统工程 编辑Hi  |  前端开发工程师 编辑 ithought.fun/ 编辑
编辑

要战胜自我,首先要战胜自己的习惯思维
实现明天理想的唯一障碍是今天的疑虑
生活不在于握有一张好牌,而在于把手里的牌打好

个人动态

司想君 收藏了文章 · 7月17日

javascript字节byte处理【经典笔记】

进制转换在线工具

获取byte的高4位bit和低4位bit
function getHeight4(ata){//获取高四位
    int height;
    height = ((data & 0xf0) >> 4);
    return height;
}

function getLow4(data){//获取低四位
    int low;
    low = (data & 0x0f);
    return low;
}
高4位和低4位合并
1字节 = 高4位(空格)低4位
例如:00001001 = 0000 1001

二进制转换成16进制
1001(二进制) 转换 0x09(十六进制)
十六进制字符串转字节数组
/**
   * 十六进制字符串转字节数组
   * 每2个字符串转换
   * 903132333435363738 转为 [-112, 49, 50, 51, 52, 53, 54, 55, 56]
   * @param {String} str 符合16进制字符串
   */
  Str2Bytes(str) {
    var pos = 0;
    var len = str.length;
    if (len % 2 != 0) {
      return null;
    }
    len /= 2;
    var hexA = new Array();
    for (var i = 0; i < len; i++) {
      var s = str.substr(pos, 2);
      var v = parseInt(s, 16);
      if(v >=127) v = v-255-1
      hexA.push(v);
      pos += 2;
    }
    return hexA;
  },
字节数组转十六进制字符串
/**
   * 字节数组转十六进制字符串
   * [-112, 49, 50, 51, 52, 53, 54, 55, 56] 转换 903132333435363738
   * @param {Array} arr 符合16进制数组
   */
  Bytes2Str(arr) {
    var str = "";
    for (var i = 0; i < arr.length; i++) {
      var tmp;
      var num=arr[i];
      if (num < 0) {
      //此处填坑,当byte因为符合位导致数值为负时候,需要对数据进行处理
        tmp =(255+num+1).toString(16);
      } else {
        tmp = num.toString(16);
      }
      if (tmp.length == 1) {
        tmp = "0" + tmp;
      }
      str += tmp;
    }
    return str;
  },
十六进制字符串转数组
 /**
   * 十六进制字符串转数组
   * 1185759ac35a91143f97037002b1a266 转换 ["11", "85", "75", "9a", "c3", "5a", "91", "14", "3f", "97", "03", "70", "02", "b1", "a2", "66"]
   * @param {String} str 十六进制字符串
   */
  Str2Arr(str) {
    var pos = 0;
    var len = str.length;
    if (len % 2 != 0) {
      return null;
    }
    len /= 2;
    var hexA = new Array();
    for (var i = 0; i < len; i++) {
      var s = str.substr(pos, 2);
      hexA.push(s);
      pos += 2;
    }
    return hexA;
  },
十六进制数组进行异或
/**
   * 十六进制数组进行异或
   * @param {Array} arr 十六进制数组
   */
  BytesDes(arr) {
    var des = 0
    for (var i = 0; i < arr.length; i++) {
      des ^= parseInt(arr[i], 16)
    }
    return des.toString(16).toUpperCase();
  },
十进制转十六进制
/**
   * 十进制转十六进制
   * 15 转 0F
   * @param {Number} num 十进制数字
   */
  toHex(num) {
    return ("0" + (Number(num).toString(16))).slice(-2).toUpperCase()
  }
二进制转十六进制
/**
   * 二进制转十六进制
   * 00001001 转 09
   * @param {String} str 二进制字符串
   */
  binTohex(str) {
    let hex_array = [{ key: 0, val: "0000" }, { key: 1, val: "0001" }, { key: 2, val: "0010" }, { key: 3, val: "0011" }, { key: 4, val: "0100" }, { key: 5, val: "0101" }, { key: 6, val: "0110" }, { key: 7, val: "0111" },
    { key: 8, val: "1000" }, { key: 9, val: "1001" }, { key: 'a', val: "1010" }, { key: 'b', val: "1011" }, { key: 'c', val: "1100" }, { key: 'd', val: "1101" }, { key: 'e', val: "1110" }, { key: 'f', val: "1111" }]
    let value = ''
    let list = []
    if (str.length % 4 !== 0) {
      let a = "0000"
      let b = a.substring(0, 4 - str.length % 4)
      str = b.concat(str)
    }
    while (str.length > 4) {
      list.push(str.substring(0, 4))
      str = str.substring(4);
    }
    list.push(str)
    for (let i = 0; i < list.length; i++) {
      for (let j = 0; j < hex_array.length; j++) {
        if (list[i] == hex_array[j].val) {
          value = value.concat(hex_array[j].key)
          break
        }
      }
    }
    return value
  }
十六进制数字和ASCII字符之间转换
var hex="0x10";//十六进制  
var charValue = String.fromCharCode(hex);//生成Unicode字符  
var charCode = charValue.charCodeAt(0);//获取指定字符的十进制表示.  
var hexOri="0x"+charCode.toString(16);;//将int值转换为十六进制  

hex:0x10
charValue:+
charCode:16
hexOri:0x10
十六进制数组转ASCLL编码
/**
   * 十六进制字符串转ASCLL编码
   * 3132333435363738 转换 12345678
   * @param {String} str 十六进制字符串
   */
  HexToAscll(str) {
    var pos = 0;
    var len = str.length/2;
    var hexA = '';
    for (var i = 0; i < len; i++) {
      var s = str.substr(pos, 2);
      hexA += String.fromCharCode(`0x${s}`);
      pos += 2;
    }
    return hexA;
  },
查看原文

司想君 收藏了文章 · 1月2日

如何开发一个基于 Vue 的 ui 组件库(二)

遗留问题

书接上回,说道利用 sideEffects 字段,只需读取源文件即可实现按需加载,还有个坑忘了说...

文档中的样式打包后会丢失...

因为我们只注意到了作为组件库的源代码,而忘了我们的文档是通过 vuepress 编译,即底层也是基于 webpack 进行打包。所以 sideEffects 中也要加上文档中的文件。

组件文档该写些什么?

在编写组件库文档时,有两个必不可少的部分。

  • 组件预览,最好有相应的代码
  • 组件 api,即 props、events、slots 等接口和参数的说明

如何同时展示 demo 和 code?

  • 最【一力降十会】的方法当然就是复制粘贴一把梭...

clipboard.png

这样实现简单是简单,不过维护时要同时改至少两份代码。比如 vant 的展示文档cube-ui 的展示文档

  • 进阶一点儿的方法就是嵌入 CodepenJSFiddleCodeSandboxiframe

但是组件库中一般有大量的组件,不可能为每个组件都维护一份小代码片段,并且别忘了这可是三个平台(硬点一个吼不吼啊~?)。

  • 因此各种组件库使用的最多的方法还是自己编写组件。(下一小节详解)
  • 当然也有例外比如 vux 只有 demo 没有 code

业界的文档组件

在 vuepress 中展示 demo 和 code

首先让我们来分析一下:这两份重复的代码应该以谁为主?即我们应该只编写 demo 的代码还是 code 的代码?先有鸡还是先有蛋?物质决定意识还是意识决定物质?

至少在编写 demo 和 code 这个问题中,我认为 demo 才是“本源”,为什么?

  • 这是最普遍的方式,各大组件库基本这么干
  • 这是最自然的方式,因为 vuepress 会编译 md 中的 vue 组件
  • 若是反过来,文档中以 code 为主,再由 code 生成 demo 会有一些不便

    • 如何预处理代码:babel、scss 等?
    • 如何插入生成的组件到文档中?
    • 感兴趣的话可以看看这个插件 vuepress-plugin-demo-block

一开始我只在 .vuepress/components/ 中建了个组件自娱自乐,后来看到了 vuepress-plugin-demo-block,但觉得由 code 生成 demo 有点儿绕。

于是自己搞了个插件 vuepress-plugin-demo-code,有需要的读者老爷可以自取~

组件的 api 文档

解决了 demo 和 code 的重复编写问题,接下来是另一个令人无发可脱的问题:如何自动生成并同步 .vue 组件的 api 文档?

手动维护肯定是不行的,还好有一个炒鸡好用的库 vuese。vuese 会基于 ast 分析你的 .vue 文件,提取其中的 props、events、slots 等接口和参数的说明。

为了将其集成到 vuepress 中,我又整了个 markdown-it 插件 markdown-it-vuese

只需使用以下的语法在导入已经存在的 *.vue 文件的同时,使用 Vuese 自动生成文档。

<[vuese](@/filePath)

以上 to be continued...

参考资料

查看原文

司想君 收藏了文章 · 1月2日

如何开发一个基于 Vue 的 ui 组件库(一)

开发模式

预览 demo

在开发一个 ui 组件库时,肯定需要一边预览 demo,一边修改代码。

常见的解决方案是像开发一般项目一样使用 webpack-dev-server 预览组件,比如通过 vue-cli 初始化项目,或者自己配置脚本。

文艺一点儿地可能会用到 parcel 来简化 demo 的开发配置(比如 muse-ui)。

展示文档

作为一个 ui 组件库,也肯定要有自己的组件展示文档。

一般业界常见方案是自己开发展示文档...

但这样会带来一个组件库和文档如何同步的问题。

为何不用 vuepress?

由于 vuepress 支持在 markdown 中插入组件,所以我们其实可以很自然地边写文档边开发组件。

从开发步骤上来说,甚至可以先写文档说明,再具体地编写代码实现组件功能。这样一来文档即是预览 demo,与组件开发可以同步更新。

p.s. React 的组件文档可以试试这俩库:

类型声明

在开发和使用过程中如果对于一些对象、方法的参数能够智能提示,岂不美哉?

如何实现呢?

其实就是在相应文件夹中添加组件相关的类型声明(*.d.ts),并通过 src/index.d.ts 导出。

{
    "typings": "src/index.d.ts",
}
一开始将声明文件都放在 types/ 文件夹下,但在实践中觉得还是放在当前文件夹下比较好。一方面有利于维护,另一方面是读取源码时也有类型提示。

如何打包

打包工具

和打包库一样,选了 rollup。

单文件组件

在开发中用不用 *.vue 这样的单文件组件来开发呢?

  • muse-ui 完全不写 <template> 只使用 render 函数。
  • iviewelementvant 使用 .vue 文件,但样式单独写。
  • ant-design-vue 使用 .jsx 文件,样式也单独写。
  • vux 使用带 <style>.vue 文件,但在使用时必须用 vux-loader
  • cube-ui 使用带 <style>.vue 文件,但有一些配置

讲道理,完全不写 <template> 有点儿麻烦,所以添加了 rollup-plugin-vue 插件用于打包 .vue 文件。

但碰到一个问题:如何打包 <style> 中的样式?

  • 首先尝试不写 <style>,直接在 js 里 import scss 文件。没问题,但是写组件时不直观,同一组件的代码也分散在了两个地方
  • 接着尝试配置 rollup-plugin-vue,碰到一个 source-map 报错的问题。我提了个 issue

加载方式

区分场景

为了区分不同的场景使用不同的 js,所以一共打包了三份 js(commonJses moduleumd),以及一份压缩后的 css(dist/tua-ui.css)。

{
    "main": "dist/TuaUI.cjs.js",
    "module": "dist/TuaUI.es.js",
}

完整加载

大部分 ui 库都支持完整加载,和把大象装冰箱一样简单(但 vux 只支持按需加载):

  1. 引入 js
  2. 引入 css
  3. 安装插件
import TuaUI from '@tencent/tua-ui'
import '@tencent/tua-ui/dist/tua-ui.css'

Vue.use(TuaUI)
因缺思厅的是 cube-ui 把基础样式也写成 Vue 插件,导致按需引入的时候还要单独引入 Style,emmmmmmmmm...
import {
  /* eslint-disable no-unused-vars */
  Style, // <-- 不写这行按需引入时就没基础样式
  Button
} from 'cube-ui'

按需加载

ui 库若是只能完整加载,显然会打包多余代码。

所以各种库一般都支持按需加载组件,大概分以下几种。

tree-shaking

webpack 其实在打包的时候是支持 tree-shaking 的,那么我们能不能直接引用源码实现按需加载呢?

注意源码必须满足 es 模块规范(import、export)。
import { TuaToast } from '@tencent/tua-ui/src/'

Vue.use(TuaToast)

尝试打包,发现 tree-shaking 并没有起作用,还是打包了所有代码。

sideEffects

其实问题出在没有在 ui 库的 package.json 中声明 sideEffects 属性。

在一个纯粹的 ESM 模块世界中,识别出哪些文件有副作用很简单。然而,我们的项目无法达到这种纯度,所以,此时有必要向 webpack 的 compiler 提供提示哪些代码是“纯粹部分”。 —— 《webpack 文档》

注意:样式部分是有副作用的!即不应该被 tree-shaking

若是直接声明 sideEffectsfalse,那么打包时将不包括样式!所以应该像下面这样配置:

{
    "sideEffects": [ "*.sass", "*.scss", "*.css" ],
}

vuepress 组件样式

用 vuepress 写文档的时候,一般会在 docs/.vuepress/components/ 下写一些全局组件。

开发时没啥问题,但是发现一个坑:打包文档时发现组件里的样式 <style> 全丢了。

猜一猜原因是什么?

这口锅就出在上一节的 sideEffects,详情看这个 issue。解决方案就是在 sideEffects 里加一条 "*.vue" 即可。

测试数据

下面咱们打包一下安装了 ui 库的项目,看看按需加载的效果怎么样。

  • Origin

    • dist/js/chunk-vendors.71ea9e72.js ----- 114.04 kb
  • TuaToast

    • dist/js/chunk-vendors.beb8cff5.js ----- 115.03 kb
    • dist/css/chunk-vendors.97c93b2d.css ----- 0.79 kb
  • TuaIcon

    • dist/js/chunk-vendors.25d8bdbd.js ----- 115.00 kb
    • dist/css/chunk-vendors.eab6517c.css ----- 6.46 kb
  • TuaUI

    • dist/js/chunk-vendors.6e0e6390.js ----- 117.39 kb
    • dist/css/chunk-vendors.7388ba27.css ----- 8.04 kb

总结一下就是:

  • 原始项目的 js 打包出来为 114.o4kb
  • 只添加 TuaToast 后 js 增加了 0.99kb,css 增加了 0.79kb
  • 只添加 TuaIcon 后 js 增加了 0.96kb,css 增加了 6.46kb
  • 添加完整 TuaUI 后 js 增加了 3.35kb,css 增加了 8.04kb

可以看出按需加载还是有效果的~

以上 to be continued...

参考资料

查看原文

司想君 赞了回答 · 2019-06-17

H5做页面,设计师切图给我2x和3x两个版本,应该用哪个呢

因为h5做移动端界面,我们都会用rem单位,我个人是切@2x的图片,如果img放在html页面里,我们只要去设置其图片的宽高即可,因为它自己就会自动缩放。但放在css里做背景图的话要用background-size:50%;缩小一半。这样的话在手机上看也不会模糊失真。至于@3x我个人也用同样的方法试过,发现跟@2x差别不大,至少在手机上看不出模糊。
所以个人建议,@2x会好点,图片小,又可以看着不模糊~

关注 9 回答 6

司想君 评论了文章 · 2018-07-16

手拉手,用Vue开发动态刷新Echarts组件

从几年前流行的jQuery插件,到现在React和Vue的组件,在业务需求的开发中抽象通用出通用的模块,一直都是一个对个人技术提高非常有帮助的事情。本文从一个真实业务组件的开发,来介绍封装一个组件应该如何从哪些方面去思考,以及在使用框架开发的今天,核心基础知识的重要性。

需求背景

dashboard作为目前企业中后台产品的“门面”,如何更加实时、高效、炫酷的对统计数据进行展示,是值得前端开发工程师和UI设计师共同思考的一个问题。今天就从0开始,封装一个动态渲染数据的Echarts折线图组件,抛砖引玉,一起来思考更多有意思的组件。

准备工作

项目结构搭建

因为生产需要(其实是懒),所以本教程使用了 ==vue-cli==进行了项目的基础结构搭建。

npm install -g vue-cli
vue init webpack vue-charts
cd vue-charts
npm run dev

安装Echarts

直接使用npm进行安装。

npm install echarts --save

引入Echarts

//在main.js加入下面两行代码
import echarts from 'echarts'
Vue.prototype.$echarts = echarts //将echarts注册成Vue的全局属性

到此,准备工作已经完成了。

静态组件开发

因为被《React编程思想》这篇文章毒害太深,所以笔者开发组件也习惯从基础到高级逐步迭代。

静态组件要实现的目的很简单,就是把Echarts图表,渲染到页面上。

新建Chart.vue文件

<template>
  <div :id="id" :style="style"></div>
</template>
<script>
export default {
  name: "Chart",
  data() {
    return {
      //echarts实例
      chart: ""
    };
  },
  props: {
    //父组件需要传递的参数:id,width,height,option
    id: {
      type: String
    },
    width: {
      type: String,
      default: "100%"
    },
    height: {
      type: String,
      default: "300px"
    },
    option: {
      type: Object,
      //Object类型的prop值一定要用函数return出来,不然会报错。原理和data是一样的,
      //使用闭包保证一个vue实例拥有自己的一份props
      default() {
        return {
          title: {
            text: "vue-Echarts"
          },
          legend: {
            data: ["销量"]
          },
          xAxis: {
            data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"]
          },
          yAxis: [
            {
              type: "value"
            }
          ],
          series: [
            {
              name: "销量",
              type: "line",
              data: [5, 20, 36, 10, 10, 70]
            }
          ]
        };
      }
    }
  },
  computed: {
    style() {
      return {
        height: this.height,
        width: this.width
      };
    }
  },
  mounted() {
    this.init();
  },
  methods: {
    init() {
      this.chart = this.$echarts.init(document.getElementById(this.id));
      this.chart.setOption(this.option);
    }
  }
};
</script>

上述文件就实现了将一个简单折线图渲染到页面的组件,怎么样是不是很简单?最简使用方法如下:

App.vue

<template>
  <div id="app">
    <Chart id="test"/>
  </div>
</template>

<script>
import Chart from "./components/Chart";
export default {
  name: "App",
  data() {},
  components: {
    Chart
  }
}
</script>

至此,运行程序你应该能看到以下效果:
图片描述

第一次迭代

现在我们已经有了一个基础版本,让我们来看看哪些方面做的还不尽如人意:

  1. 图表无法根据窗口大小进行自动缩放,虽然设置了宽度为100%,但是只有刷新页面图表才会重新进行渲染,这会让用户体验变得很差。
  2. 图表目前无法实现数据自动刷新

下面我们来实现这两点:

自动缩放

Echarts本身是不支持自动缩放的,但是Echarts为我们提供了resize方法。

//在init方法中加入下面这行代码
window.addEventListener("resize", this.chart.resize);

只需要这一句,我们就实现了图表跟随窗口大小自适应的需求。

支持数据自动刷新

因为Echarts是数据驱动的,这意味着只要我们重新设置数据,那么图表就会随之重新渲染,这是实现本需求的基础。我们再设想一下,如果想要支持数据的自动刷新,必然需要一个监听器能够实时监听到数据的变化然后告知Echarts重新设置数据。所幸Vue为我们提供了watcher功能,通过它我们可以很方便的实现上述功能:

//在Chart.vue中加入watch
  watch: {
    //观察option的变化
    option: {
      handler(newVal, oldVal) {
        if (this.chart) {
          if (newVal) {
            this.chart.setOption(newVal);
          } else {
            this.chart.setOption(oldVal);
          }
        } else {
            this.init();
        }
      },
      deep: true //对象内部属性的监听,关键。
    }
  }

上面代码就实现了我们对option对象中属性变化的监听,一旦option中的数据有了变化,那么图表就会重新渲染。

实现动态刷新

下一步我想大家都知道了,就是定时从后台拉取数据,然后更新父组件的option就好。这个地方有两个问题需要思考一下:

  1. 如果图表要求每秒增加一个数据,应该如何进行数据的请求才能达到性能与用户体验的平衡?
  2. 动态更新数据的代码,应该放在父组件还是子组件?

对第一个问题,每秒实时获取服务器的数据,肯定是最精确的,这就有两种方案:

  • 每秒向后台请求一次
  • 保持长连接,后台每秒向前端推送一次数据

第一种方案无疑对性能和资源产生了极大的浪费;除非实时性要求特别高(股票系统),否则不推荐这种方式;

第二种方案需要使用web Socket,但在服务端需要进行额外的开发工作。

笔者基于项目的实际需求(实时性要求不高,且后台生成数据也有一定的延迟性),采用了以下方案:

  1. 前端每隔一分钟向后台请求一次数据,且为当前时间的上一分钟的数据;
  2. 前端将上述数据每隔一秒向图表set一次数据

关于第二个问题:笔者更倾向于将Chart组件设计成纯组件,即只接收父组件传递的数据进行变化,不在内部进行复杂操作;这也符合目前前端MVVM框架的最佳实践;而且若将数据传递到Chart组件内部再进行处理,一是遇到不需要动态渲染的需求还需要对组件进行额外处理,二是要在Chart内部做ajax操作,这样就导致Chart完全没有了可复用性。

接下来我们修改App.vue

<template>
  <div id="app">
    <Chart id="test" :option="option"/>
  </div>
</template>

<template>
  <div id="app">
    <Chart id="test" :option="option"/>
  </div>
</template>

<script>
import Chart from "./components/Chart";
export default {
  name: "App",
  data() {
    return {
      //笔者使用了mock数据代表从服务器获取的数据
      chartData: {
        xData: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"],
        sData: [5, 20, 36, 10, 10, 70]
      },
      option: {
        title: {
          text: "vue-Echarts"
        },
        legend: {
          data: ["销量"]
        },
        xAxis: {
          data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"]
        },
        yAxis: [
          {
            type: "value"
          }
        ],
        series: [
          {
            name: "销量",
            type: "line",
            data: [5, 20, 36, 10, 10, 70]
          }
        ]
      }
    };
  },
  components: {
    Chart
  },
  mounted() {
    this.refreshData();
  },
  methods: {
    //添加refreshData方法进行自动设置数据
    refreshData() {
      //横轴数据
      let xData = this.chartData.xData,
        //系列值
        sData = this.chartData.sData;
      for (let i = 0; i < xData.length; i++) {
        //此处使用let是关键,也可以使用闭包。原理不再赘述
        setTimeout(() => {
          this.option.xAxis.data.push(xData[i]);
          this.option.series[0].data.push(sData[i]);
        }, 1000 * i); //此处要理解为什么是1000*i
      }
    }
  }
};
</script>


至此我们就实现了图表动态数据加载,效果如下图:

图片描述

总结

这篇教程通过一个动态图表的开发,传递了以下信息:

  • Echarts如何与Vue结合使用
  • Vue组件开发、纯组件与“脏”组件的区别
  • Vue watch的用法
  • let的特性
  • JavaScript EventLoop特性
  • ...

大家可以根据这个列表查漏补缺。

后续优化

这个组件还有需要需要优化的点,比如:

  1. 间隔时间应该可配置
  2. 每分钟从后台获取数据,那么图表展示的数据将会越来越多,越来越密集,浏览器负担越来越大,直到崩溃
  3. 没有设置暂停图表刷新的按钮
  4. ...

期待大家自己动手,开发一个属于自己的“完美”的Echarts动态组件!

查看原文

司想君 回答了问题 · 2018-06-29

elementUI表格组件多选问题,怎么设置两个默认选择

关注 4 回答 3

司想君 赞了回答 · 2018-06-29

elementUI表格组件多选问题,怎么设置两个默认选择

在数据返回渲染页面之后使用toggleRowSelection,row是表格数据每行数据,如想设置1/2行,那么row是[tableData3[1], tableData3[2]]

rows.forEach(row => {
    this.$refs.multipleTable.toggleRowSelection(row);
 });

关注 4 回答 3

司想君 赞了文章 · 2018-06-04

nodejs微服务解决方案

前言

seneca是一个nodejs微服务工具集,它赋予系统易于连续构建和更新的能力。下面会逐一和大家一起了解相关技术入门以及实践。

这里插入一段硬广。小子再进行简单整合之后撸了个vastify框架 ---- 轻量级nodejs微服务框架,有兴趣的同学过目一下,欢迎顺手star一波,另外有疑问或者代码有毛病欢迎在博文下方留言。

环境

  • 基础环境
"node": "^10.0.0"
"npm": "^6.0.0"
"pm2": "^2.10.3"
"rabbitmq": "^3.7.5"
"consul": "^1.1.0"
"mongodb": "^3.6"
  • 微服务工程
"bluebird": "^3.5.1"
"koa": "^2.5.1"
"koa-router": "^7.4.0"
"seneca": "^3.4.3"
"seneca-web": "^2.2.0"
"seneca-web-adapter-koa2": "^1.1.0"
"amqplib": "^0.5.2"
"winston": "^2.4.2"
"mongoose": "^5.1.2"

FEATURES

  • 模式匹配做服务间调用:略微不同于SpringCloud服务发现(http协议、IP + PORT模式),它使用更加灵活的模式匹配(Patrun模块)原则去进行微服务间的调用
  • 接入koa2对C端提供RESTFUl API
  • 插件:更灵活编写小而微的可复用模块
  • seneca内置日志输出
  • 第三方日志库比较winston(选用)、bunyan、log4js
  • RabbitMQ消息队列
  • PM2:node服务部署(服务集群)、管理与监控
  • PM2:自动化部署
  • PM2集成docker
  • 请求追踪(重建用户请求流程)
  • 梳理Consul 服务注册与发现基本逻辑
  • 框架集成node-consul
  • mongodb持久化存储
  • 结合seneca与consul的路由服务中间件(可支持多个相同名字服务集群路由,通过$$version区别)
  • 支持流处理(文件上传/下载等)
  • jenkins自动化部署
  • nginx负载均衡
  • 持续集成方案
  • redis缓存
  • prisma提供GraphQL接口

模式匹配(Patrun模块)

index.js(accout-server/src/index.js)

const seneca = require('seneca')()

seneca.use('cmd:login', (msg, done) => {
    const { username, pass } = msg
    if (username === 'asd' && pass === '123') {
        return done(null, { code: 1000 })
    }
    return done(null, { code: 2100 })
})

const Promise = require('bluebird')

const act = Promise.promisify(seneca.act, { context: 'seneca' })

act({
    cmd: 'login',
    username: 'asd',
    pass: '123'
}).then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})

执行后

{ code: 1000 }
{"kind":"notice","notice":"hello seneca k5i8j1cvw96h/1525589223364/10992/3.4.3/-","level":"info","seneca":"k5i8j1cvw96h/1525589223364/10992/3.4.3/-","when":1525589223563}

seneca.add方法,添加一个action pattern到Seneca实例中,它有三个参数:

  1. pattern: 用于Seneca中JSON的消息匹配模式,对象或格式化字符串
  2. sub_pattern: 子模式,优先级低于主模式(可选)
  3. action: 当匹配成功后的动作函数

seneca.act方法,执行Seneca实例中匹配成功的动作,它也有两个参数:

  1. msg: JSON消息
  2. sub_pattern: 子消息,优先级低于主消息(可选)
  3. response: 用于接收服务调用结果

seneca.use方法,为Seneca实例添加一个插件,它有两个参数:(此处插件的原理和中间件有一些不同)

  1. func: 插件执行方法
  2. options: 插件所需options(可选)

核心是利用JSON对象进行模式匹配。这个JSON对象既包含某个微服务所需要调取另一个微服务的特征,同时也包含传参。和Java微服务发现有些类似不过是用模式代替ip+port,目前为止模式是完全可以实现服务发现功能,但是否更加灵活还有待去挖掘。

所需注意的点

  • 各微服务之间模式需通过设计来区分

启动第一个微服务

index.js(config-server/src/index.js)

const seneca = require('seneca')()
const config = {
SUCCESS_NORMAL_RES: {
    code: 1000,
    desc: '服务端正常响应'
}}

seneca.add('$target$:config-server', (msg, done) => {
  return done(null, config)
}).listen(10011)

运行此脚本后可在浏览器中输入http://localhost:10011/act?cmd=config发起请求获取全局配置信息

OR

const seneca = require('seneca')()
const Promise = require('bluebird')

const act = Promise.promisify(seneca.act, { context: seneca })

seneca.client(10011)
act('$$target:config-server, default$:{msg:404}').then(res => {
  console.log(res)
}).catch(err => {
  console.log(err)
})

对内:多个微服务相互调用(关键)

noname-server

const seneca = require('seneca')()
seneca.add('$$target:account-server', (msg, done) => {
    done(null, { seneca: '666' })
})
seneca.listen(10015)

config-server(同上)

call

const seneca = require('seneca')()
const Promise = require('blurebird')

const act = Promise.promisify(seneca.act, { context: seneca })

seneca.client({
    port: '10011',
    pin: '$$target:account-server'
})
seneca.client({
    port: '10015',
    pin: '$$target:noname-server'
})

act('$$target:account-server').then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})

act('$$target:noname-server').then(res => {
    console.log(res)
}).catch(err => {
    console.log(err)
})

对外:提供REST服务(关键)

集成koa

const seneca = require('seneca')()
const Promise = require('bluebird')
const SenecaWeb = require('seneca-web')
const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const userModule = require('./modules/user.js')

// 初始化用户模块
seneca.use(userModule.init)

// 初始化seneca-web插件,并适配koa
seneca.use(SenecaWeb, {
  context: Router(),
  adapter: require('seneca-web-adapter-koa2'),
  routes: [...userModule.routes]
})

// 将routes导出给koa app
seneca.ready(() => {
  app.use(seneca.export('web/context')().routes())
})

app.listen(3333)

user模块

const $module = 'module:user'
let userCount = 3

const REST_Routes = [
  {
    prefix: '/user',
    pin: `${$module},if:*`,
    map: {
      list: {
        GET: true,
        name: ''
      },
      load: {
        GET: true,
        name: '',
        suffix: '/:id'
      },
      edit: {
        PUT: true,
        name: '',
        suffix: '/:id'
      },
      create: {
        POST: true,
        name: ''
      },
      delete: {
        DELETE: true,
        name: '',
        suffix: '/:id'
      }
    }
  }
]

const db = {
  users: [{
    id: 1,
    name: '甲'
  }, {
    id: 2,
    name: '乙'
  }, {
    id: 3,
    name: '丙'
  }]
}

function user(options) {
  this.add(`${$module},if:list`, (msg, done) => {
    done(null, db.users)
  })
  this.add(`${$module},if:load`, (msg, done) => {
    const { id } = msg.args.params
    done(null, db.users.find(v => Number(id) === v.id))
  })
  this.add(`${$module},if:edit`, (msg, done) => {
    let { id } = msg.args.params
    id = +id
    const { name } = msg.args.body
    const index = db.users.findIndex(v => v.id === id)
    if (index !== -1) {
      db.users.splice(index, 1, {
        id,
        name
      })
      done(null, db.users)
    } else {
      done(null, { success: false })
    }
  })
  this.add(`${$module},if:create`, (msg, done) => {
    const { name } = msg.args.body
    db.users.push({
      id: ++userCount,
      name
    })
    done(null, db.users)
  })
  this.add(`${$module},if:delete`, (msg, done) => {
    let { id } = msg.args.params
    id = +id
    const index = db.users.findIndex(v => v.id === id)
    if (index !== -1) {
      db.users.splice(index, 1)
      done(null, db.users)
    } else {
      done(null, { success: false })
    }
  })
}

module.exports = {
  init: user,
  routes: REST_Routes
}

vscode-restclient(vscode的restclient插件,用于发起RESTFUL请求)

### 1
POST http://localhost:3333/user HTTP/1.1
Content-Type: application/json

{
  "name": "测试添加用户"
}

### delete
DELETE http://localhost:3333/user/2 HTTP/1.1

### PUT
PUT http://localhost:3333/user/2 HTTP/1.1
Content-Type: application/json

{
  "name": "测试修改用户信息"
}

### GET
GET http://localhost:3333/user HTTP/1.1

### GET
GET http://localhost:3333/user/3 HTTP/1.1

seneca内置日志输出

可在构造函数中传入配置,log属性可以控制日志级别

例1:传字符串

require('seneca')({
    // quiet silent any all print standard test
    log: 'all'
})

例2:传对象

require('seneca')({
    log: {
        // none debug+ info+ warn+
        level: 'debug+'
    },
    // 设置为true时,seneca日志功能会encapsulate senecaId,senecaTag,actId等字段后输出(一般为两字符)
    short: true
})

建议例2代码,因为seneca-web-adapter-koa2插件打印的日志level为debug,利于做web接口访问日志记录。

winston日志模块

传送门

Logger.js

const { createLogger, format, transports } = require('winston')
const { combine, timestamp, label, printf } = format

const logger = createLogger({
  level: 'info',
  format: combine(
    label({label: 'microservices'}),
    timestamp(),
    printf(info => {
      return `${info.timestamp} [${info.label}] ${info.level}: ${info.message}`
    })
  ),
  transports: [ new transports.Console() ]
})

// highest to lowest
const levels = {
  error: 0,
  warn: 1,
  info: 2,
  verbose: 3,
  debug: 4,
  silly: 5
}

module.exports = logger

日志输出格式

2018-05-17T14:43:28.330Z [microservices] info: 接收到rpc客户端的调用请求
2018-05-17T14:43:28.331Z [microservices] warn: warn message
2018-05-17T14:43:28.331Z [microservices] error: error message

RabbitMQ消息队列服务

1. 单任务单consumer,生产者消费者模式

producer.js

// 创建一个amqp对等体
const amqp = require('amqplib/callback_api')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const q = 'taskQueue1'
    const msg = process.argv.slice(2).join(' ') || 'hello world'

    // 为方式RabbitMQ退出或者崩溃时重启后丢失队列信息,这里配置durable:true(同时在消费者脚本中也要配置durable:true)后,
    ch.assertQueue(q, { durable: true })
    // 这里配置persistent:true,通过阅读官方文档,我理解为当程序重启后,会断点续传之前未send完成的数据消息。(但此功能并不可靠,因为不会为所有消息执行同步IO,会缓存在cache并在某个恰当时机write到disk)
    ch.sendToQueue(q, Buffer.from(msg), { persistent: true })
    setTimeout(() => {
      conn.close(); process.exit(0)
    }, 100)
  })
})
// 创建一个amqp对等体
const amqp = require('amqplib/callback_api')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const q = 'taskQueue1'

    // 为方式RabbitMQ退出或者崩溃时重启后丢失队列信息,这里配置durable:true(同时在消费者脚本中也要定义durable:true)后,
    ch.assertQueue(q, { durable: true })
    ch.prefetch(1)
    console.log(" [*] Waiting for messages in %s. To exit press CTRL+C", q)
    ch.consume(q, msg => {
      const secs = msg.content.toString().split('.').length - 1
      console.log(" [x] Received %s", msg.content.toString())
      setTimeout(() => {
        console.log(" [x] Done")
        ch.ack(msg)
      }, secs * 1000)
    })
    // noAck配置(默认为false)表明consumer是否需要在处理完后反馈ack给producer,如果设置为true,则RabbitMQ服务如果将任务send至此consumer后不关心任务实际处理结果,send任务后直接标记已完成;否则,RabbiMQ得到ack反馈后才标记为已完成,如果一直未收到ack默认会一直等待ack然后标记,另外如果接收到nack或者该consumer进程退出则继续dispatcher任务
  })
})

检验过程

  • 执行rabbitmqctl list_queues查看当前队列
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
  • node producer.js(rabbitMQ执行过程为会先创建一个匿名exchange,一个指定queue然后将queue与该匿名exchange绑定)
  • rabbitmqctl list_bindings
Listing bindings for vhost /...
        exchange        taskQueue1      queue   taskQueue1      []
  • rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
taskQueue1      1
  • node consumer.js
Waiting for messages in taskQueue1. To exit press CTRL+C
[x] Received hello world
[x] Done
  • rabbitmqctl list_queues
Timeout: 60.0 seconds ...
Listing queues for vhost / ...
taskQueue1      0

知识点

  • 生产者消费者模式(一个生产者的消息在同一时间只交由一个消费者处理)
  • ACK机制(rabbitmq的确认机制)
  • 创建队列{durable:true}以及向队列发送消息{persistent:true}(消息持久化存储,但不完全能保证,比如当某消息未从缓存中写到磁盘中而程序崩溃时则会丢失)
  • Round-robin Dispatch(公平分发)
  • 处理窗口控制(prefetch来控制分发窗口)
  • 异步多任务处理机制(比如一个大任务分解,分而治之)
  • 整个消息流流程(某个生产者进程 -> 匿名exchange -> 通过binding -> 指定queue -> 某一个消费者进程)

2. 单任务多consumer,发布/订阅模式(全消息模型)

publisher.js

const amqp = require('amqplib/callback_api')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const ex = 'logs'
    const msg = process.argv.slice(2).join(' ') || 'Hello World!'

    // ex为exchange名称(唯一)
    // 模式为fanout
    // 不对消息持久化存储
    ch.assertExchange(ex, 'fanout', { durable: false })
    // 第二个参数为指定某一个binding,如为空则由RabbitMQ随机指定
    ch.publish(ex, '', Buffer.from(msg))
    console.log(' [x] Send %s', msg)
  })

  setTimeout(() => {
    conn.close()
    process.exit(0)
  }, 100)
})

subscriber.js

const amqp = require('amqplib/callback_api')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const ex = 'logs'

    // ex -> exchange是发布/订阅消息的载体,
    // fanout -> 分发消息的模式,fanout,direct,topic,headers
    // durable设置为false降低一些可靠性,提高性能,因为不需要磁盘IO持久化存储消息,另外
    ch.assertExchange(ex, 'fanout', { durable: false })
    // 使用匿名(也就是RabbitMQ自动生成随机名的queue)队列
    // exclusive设置为true,即可以当其寄生的connection被close的时候自动deleted
    ch.assertQueue('', { exclusive: true }, (err, q) => {
      console.log(" [*] Waiting for messages in %s. To exit press CTRL+C", q.queue)
      // 绑定队列到某个exchange载体(监听某个exchange的消息)
      // 第三个入参为binding key
      ch.bindQueue(q.queue, ex, '')
      // 消费即订阅某个exchange的消息并设置处理句柄
      // 因为发布/订阅消息的模式就是非可靠性,只有当订阅者订阅才能收到相关的消息而且发布者不关心该消息的订阅者是谁以及处理结果如何,所以这里noAck会置为true
      ch.consume(q.queue, (msg) => {
        console.log(' [x] %s', msg.content.toString())
      }, { noAck: true })
    })
  })
})

检验过程

rabbitmqctl stop_app;rabbitmqctl reset;rabbitmqctl start_app(清空之前测试使用的queues、echanges、bindings)

node subscriber.js

[*] Waiting for messages in amq.gen-lgNW51IeEfj9vt1yjMUuaw. To exit press CTRL+C

rabbitmqctl list_exchanges

Listing exchanges for vhost / ...
logs    fanout

rabbitmqctl list_bindings

Listing bindings for vhost /...
        exchange        amq.gen-jDbfwJR8TbSNJT2a2a83Og  queue   amq.gen-jDbfwJR8TbSNJT2a2a83Og  []
logs    exchange        amq.gen-jDbfwJR8TbSNJT2a2a83Og  queue           []

node publisher.js tasks.........

[x] Send tasks......... // publiser.js

[x] tasks......... // subscriber.js

知识点

  • 发布/订阅模式(发布者将消息以一对多的形式发送给订阅者处理)
  • noAck(此模式下推荐用非Ack机制,因为发布者往往不需要订阅者如何处理消息以及其结果)
  • durable:false(此模式下推荐不需要做数据持久化存储,原因如上)
  • exchange的工作模式(即路由类型,fanout,direct,topic,headers等,下节会讲解到)
  • 整个消息流流程(某个发布者进程 -> 指定exchange -> 通过binding以及工作模式 -> 某个或多个匿名queue即订阅者进程)

3. Direct Routing

exchange.js

module.exports = {
  name: 'ex1',
  type: 'direct',
  option: {
    durable: false
  },
  ranks: ['info', 'error', 'warning', 'severity']
}

direct-routing.js

const amqp = require('amqplib/callback_api')
const ex = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {

    ch.assertExchange(ex.name, ex.type, ex.options)
    setTimeout(() => {
      conn.close()
      process.exit(0)
    }, 0)
  })
})

subscriber.js

const amqp = require('amqplib/callback_api')
const ex = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const ranks = ex.ranks

    ranks.forEach(rank => {
      // 声明一个非匿名queue
      ch.assertQueue(`${rank}-queue`, { exclusive: false }, (err, q) => {
        ch.bindQueue(q.queue, ex.name, rank)
        ch.consume(q.queue, msg => {

          console.log(" [x] %s: '%s'", msg.fields.routingKey, msg.content.toString());
        }, { noAck: true })
      })
    })
  })
})

publisher.js

const amqp = require('amqplib/callback_api')
const ex = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const ranks = ex.ranks

    ranks.forEach(rank => {
      ch.publish(ex.name, rank, Buffer.from(`${rank} logs...`))
    })

    setTimeout(() => {
      conn.close()
      process.exit(0)
    }, 0)
  })
})

检验过程

rabbitmqctl stop_app;rabbitmqctl reset;rabbitmqctl start_app(清空之前测试使用的queues、echanges、bindings)

node direct-routing.js
rabbitmqctl list_exchanges

Listing exchanges for vhost / ...
amq.headers    headers
ex1    direct
amq.fanout    fanout
amq.rabbitmq.trace    topic
amq.topic    topic
    direct
amq.direct    direct
amq.match    headers

node subscriber.js
rabbitmqctl list_queues

Timeout: 60.0 seconds ...
Listing queues for vhost / ...
severity-queue    0
error-queue    0
info-queue    0
warning-queue    0

Listing bindings for vhost /...
    exchange    error-queue    queue    error-queue    []
    exchange    info-queue    queue    info-queue    []
    exchange    severity-queue    queue    severity-queue    []
    exchange    warning-queue    queue    warning-queue    []
ex1    exchange    error-queue    queue    error    []
ex1    exchange    info-queue    queue    info    []
ex1    exchange    severity-queue    queue    severity    []
ex1    exchange    warning-queue    queue    warning    []

node publisher.js

 [x] info: 'info logs...'
 [x] error: 'error logs...'
 [x] severity: 'severity logs...'
 [x] warning: 'warning logs...'

知识点

  • 路由key,用于exchange的direct工作模式下消息的路由
  • 每当assertQueue时,该queue会在以queue名称当作路由key绑定到匿名exchange
  • 可用于日志不同级别的log处理

4. Topic Routing

exchange.js

module.exports = {
  name: 'ex2',
  type: 'topic',
  option: {
    durable: false
  },
  ranks: ['info', 'error', 'warning', 'severity']
}

topic-routing.js

const amqp = require('amqplib/callback_api')
const exchangeConfig = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    ch.assertExchange(exchangeConfig.name, exchangeConfig.type, exchangeConfig.option)

    setTimeout(() => {
      conn.close()
      process.exit(0)
    }, 0)
  })
})

subscriber.js

const amqp = require('amqplib/callback_api')
const exchangeConfig = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const args = process.argv.slice(2)
    const keys = (args.length > 0) ? args : ['anonymous.info']

    console.log(' [*] Waiting for logs. To exit press CTRL+C');
    keys.forEach(key => {
      ch.assertQueue('', { exclusive: true }, (err, q) => {
        console.log(` [x] Listen by routingKey ${key}`)
        ch.bindQueue(q.queue, exchangeConfig.name, key)

        ch.consume(q.queue, msg => {
          console.log(" [x] %s:'%s'", msg.fields.routingKey, msg.content.toString());
        }, { noAck: true })
      })
    })
  })
})

publisher.js

const amqp = require('amqplib/callback_api')
const exchangeConfig = require('./exchange')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const args = process.argv.slice(2)
    const key = (args.length > 1) ? args[0] : 'anonymous.info'
    const msg = args.slice(1).join(' ') || 'hello world'

    ch.publish(exchangeConfig.name, key, Buffer.from(msg))

    setTimeout(() => {
      conn.close()
      process.exit(0)
    }, 0)
  })
})

检验过程

rabbitmqctl stop_app;rabbitmqctl reset;rabbitmqctl start_app(清空之前测试使用的queues、echanges、bindings)

node topic-routing.js

Listing exchanges for vhost / ...
amq.fanout    fanout
amq.rabbitmq.trace    topic
amq.headers    headers
amq.match    headers
ex2    topic
    direct
amq.topic    topic
amq.direct    direct

node subscriber.js "#.info" "*.error"

[*] Waiting for logs. To exit press CTRL+C
[x] Listen by routingKey #.info
[x] Listen by routingKey *.error
  • node publisher.js "account-server.info" "用户服务测试"
  • node publisher.js "config-server.info" "配置服务测试"
  • node publisher.js "config-server.error" "配置服务出错"
[x] account-server.info:'用户服务测试'
[x] config-server.info:'配置服务测试'
[x] config-server.error:'配置服务出错'

知识点

  • key最长为255字节
  • #可匹配0或多个单词,*可精确匹配1个单词

5. RPC

rpc_server.js

const amqp = require('amqplib/callback_api')
const logger = require('./Logger')

let connection = null

amqp.connect('amqp://localhost', (err, conn) => {
  connection = conn
  conn.createChannel((err, ch) => {
    const q = 'account_rpc_queue'

    ch.assertQueue(q, { durable: true })
    ch.prefetch(2)

    ch.consume(q, msg => {
      let data = {}
      let primitiveContent = msg.content.toString()
      try {
        data = JSON.parse(primitiveContent)
      } catch (e) {
        logger.error(new Error(e))
      }
      logger.info('接收到rpc客户端的调用请求')
      if (msg.properties.correlationId === '10abc') {
        logger.info(primitiveContent)
        const uid = Number(data.uid) || -1
        let r = getUserById(uid)
        ch.sendToQueue(msg.properties.replyTo, Buffer.from(JSON.stringify(r)), { persistent: true })
        ch.ack(msg)
      } else {
        logger.info('不匹配的调用请求')
      }
    })
  })
})

function getUserById (uid) {
  let result = ''

  if (uid === +uid && uid > 0) {
    result = {
      state: 1000,
      msg: '成功',
      data: {
        uid: uid,
        name: '小强',
        sex: 1
      }
    }
  } else {
    result = {
      state: 2000,
      msg: '传参格式错误'
    }
  }

  return result
}

process.on('SIGINT', () => {
  logger.warn('SIGINT')
  connection && connection.close()
  process.exit(0)
})

rpc_client.js

const amqp = require('amqplib/callback_api')

amqp.connect('amqp://localhost', (err, conn) => {
  conn.createChannel((err, ch) => {
    const q = 'account_rpc_queue'
    const callback = 'callback_queue'

    ch.assertQueue(callback, { durable: true })
    ch.consume(callback, msg => {
      const result = msg.content.toString()
      console.log(`接收到回调的消息啦!`)
      console.log(result)
      ch.ack(msg)
      setTimeout(() => {
        conn.close()
        process.exit(0)
      }, 0)
    })

    ch.assertQueue(q, { durable: true })
    const msg = {
      uid: 2
    }
    ch.sendToQueue(q, Buffer.from(JSON.stringify(msg)), {
      persistent: true,
      correlationId: '10abc',
      replyTo: 'callback_queue'
    })
  })
})

检验过程

node rpc_server.js

rabbitmqctl list_queues

Timeout: 60.0 seconds ...
Listing queues for vhost / ...
account_rpc_queue    0

node rpc_client.js

rpc_client的CLI打印

接收到回调的消息啦!
{"state":1000,"msg":"成功","data":{"uid":2,"name":"小强","sex":1}}

rpc_server的CLI打印

接收到rpc客户端的调用请求
{ uid: 2 }

PM2:node服务部署(服务集群)、管理与监控

pm2官网

启动

pm2 start app.js

  • -w --watch:监听目录变化,如变化则自动重启应用
  • --ignore-file:监听目录变化时忽略的文件。如pm2 start rpc_server.js --watch --ignore-watch="rpc_client.js"
  • -n --name:设置应用名字,可用于区分应用
  • -i --instances:设置应用实例个数,0与max相同
  • -f --force: 强制启动某应用,常常用于有相同应用在运行的情况
  • -o --output <path>:标准输出日志文件的路径
  • -e --error <path>:错误输出日志文件的路径
  • --env <path>:配置环境变量

pm2 start rpc_server.js -w -i max -n s1 --ignore-watch="rpc_client.js" -e ./server_error.log -o ./server_info.log

在cluster-mode,也就是-i max下,日志文件会自动在后面追加-${index}保证不重复

其他简单且常用命令

pm2 stop app_name|app_id
pm2 restart app_name|app_id
pm2 delete app_name|app_id
pm2 show app_name|app_id OR pm2 describe app_name|app_id
pm2 list
pm2 monit
pm2 logs app_name|app_id --lines <n> --err

Graceful Stop

pm2 stop app_name|app_id

process.on('SIGINT', () => {
  logger.warn('SIGINT')
  connection && connection.close()
  process.exit(0)
})

当进程结束前,程序会拦截SIGINT信号从而在进程即将被杀掉前去断开数据库连接等等占用内存的操作后再执行process.exit()从而优雅的退出进程。(如在1.6s后进程还未结束则继续发送SIGKILL信号强制进程结束)

Process File

ecosystem.config.js

const appCfg = {
  args: '',
  max_memory_restart: '150M',
  env: {
    NODE_ENV: 'development'
  },
  env_production: {
    NODE_ENV: 'production'
  },
  // source map
  source_map_support: true,
  // 不合并日志输出,用于集群服务
  merge_logs: false,
  // 常用于启动应用时异常,超时时间限制
  listen_timeout: 5000,
  // 进程SIGINT命令时间限制,即进程必须在监听到SIGINT信号后必须在以下设置时间结束进程
  kill_timeout: 2000,
  // 当启动异常后不尝试重启,运维人员尝试找原因后重试
  autorestart: false,
  // 不允许以相同脚本启动进程
  force: false,
  // 在Keymetrics dashboard中执行pull/upgrade操作后执行的命令队列
  post_update: ['npm install'],
  // 监听文件变化
  watch: false,
  // 忽略监听文件变化
  ignore_watch: ['node_modules']
}

function GeneratePM2AppConfig({ name = '', script = '', error_file = '', out_file = '', exec_mode = 'fork', instances = 1, args = "" }) {
  if (name) {
    return Object.assign({
      name,
      script: script || `${name}.js`,
      error_file: error_file || `${name}-err.log`,
      out_file: out_file|| `${name}-out.log`,
      instances,
      exec_mode: instances > 1 ? 'cluster' : 'fork',
      args
    }, appCfg)
  } else {
    return null
  }
}

module.exports = {
  apps: [
    GeneratePM2AppConfig({
      name: 'client',
      script: './rpc_client.js'
    }),

    GeneratePM2AppConfig({
      name: 'server',
      script: './rpc_server.js',
      instances: 1
    })
  ]
}

pm2 start ecosystem.config.js

避坑指南:processFile文件命名建议为*.config.js格式。否则后果自负。

监控

请移步app.keymetrics.io

PM2:自动化部署

ssh准备

  1. ssh-keygen -t rsa -C 'qingf deployment' -b 4096
  2. 如果有多密钥、多用户情况,建议配置~/.ssh/config文件,格式类似如下
// 用不同用户对不同远程主机发起ssh请求时指定私钥
Host qingf.me
  User deploy
  IdentityFile ~/.ssh/qf_deployment_rsa
  // 设置为no可去掉首次登陆(y/n)的选择
  StrictHostKeyChecking no
// 别名用法
Host deployment
  User deploy
  Hostname qingf.me
  IdentityFile ~/.ssh/qingf_deployment_rsa
  StrictHostKeyChecking no
  1. 将公钥复制到远程(一般为部署服务器)对应用户目录,比如/home/deploy/.ssh/authorized_keys文件(authorized_keys文件权限设置为600)

配置ecosystem.config.js

与上述apps同级增加deploy属性,如下

deploy: {
    production: {
        'user': 'deploy',
        'host': 'qingf.me',
        'ref': 'remotes/origin/master',
        'repo': 'https://github.com/Cecil0o0/account-server.git',
        'path': '/home/deploy/apps/account-server',
        // 生命周期钩子,在ssh到远端之后setup操作之前执行
        'pre-setup': '',
        // 生命周期钩子,在初始化设置即git pull之后执行
        'post-setup': 'ls -la',
        // 生命周期钩子,在远端git fetch origin之前执行
        'pre-setup': '',
        // 生命周期钩子,在远端git修改HEAD指针到指定ref之后执行
        'post-deploy': 'npm install && pm2 startOrRestart deploy/ecosystem.config.js --env production',
        // 以下这个环境变量将注入到所有app中
        "env"  : {
          "NODE_ENV": "test"
        }
    }
}
tip:please make git working directory clean first!

此处如果不懂或者有疑问,请查阅Demo

然后先后执行以下两条命令(注意config文件路径)

  1. pm2 deploy deploy/ecosystem.config.js production setup
  2. pm2 deploy deploy/ecosystem.config.js production

其他命令

pm2 deploy <configuration_file> <environment> <command>

  Commands:
    setup                run remote setup commands
    update               update deploy to the latest release
    revert [n]           revert to [n]th last deployment or 1
    curr[ent]            output current release commit
    prev[ious]           output previous release commit
    exec|run <cmd>       execute the given <cmd>
    list                 list previous deploy commits
    [ref]                deploy to [ref], the "ref" setting, or latest tag

推荐shell toolkit

oh my zsh

请求追踪

如何?

  • 在seneca.add以及seneca.act中使用seneca.fixedargs['tx$']值作为traceID标识处于某一条请求流程。另外seneca内置log系统会打印此值。

疑问?

seneca内置log系统如何做自定义日志打印?

温馨提示:请以正常的http请求开始,因为经过测试如果微服务自主发起act,其seneca.fixedargs['tx$']值不同。

Consul 服务注册与发现

Consul是一个分布式集群服务注册发现工具,并具有健康检查、分级式KV存储、多数据中心等高级特性。

安装

  • 可选择使用预编译的安装包
  • 也可选择克隆源码后编译安装

基础使用

  • 以开发模式快速启动服务模式代理并开启web界面访问http://localhost:8500

consul agent -dev -ui

  • 编写服务定义文件
{
  "service": {
    // 服务名,稍后用于query服务
    "name": "account-server",
    // 服务标签
    "tags": ["account-server"],
    // 服务元信息
    "meta": {
      "meta": "for my service"
    },
    // 服务端口
    "port": 3333,
    // 不允许标签覆盖
    "enable_tag_override": false,
    // 脚本检测做health checks 与-enable-script-checks=true配合使用,有脚本模式、TCP模式、HTTP模式、TTL模式
    "checks": [
      {
        "http": "http://localhost:3333/user",
        "interval": "10s"
      }
    ]
  }
}
  • query定义的account-server服务

curl http://localhost:8500/v1/catalog/service/account-server

[
    {
        "ID": "e66eb1ff-460c-e63f-b4ac-0cb42daed19c",
        "Node": "haojiechen.local",
        "Address": "127.0.0.1",
        "Datacenter": "dc1",
        "TaggedAddresses": {
            "lan": "127.0.0.1",
            "wan": "127.0.0.1"
        },
        "NodeMeta": {
            "consul-network-segment": ""
        },
        "ServiceID": "account-server",
        "ServiceName": "account-server",
        "ServiceTags": [
            "account-server"
        ],
        "ServiceAddress": "",
        "ServiceMeta": {
            "meta": "for my service"
        },
        "ServicePort": 3333,
        "ServiceEnableTagOverride": false,
        "CreateIndex": 6,
        "ModifyIndex": 6
    }
]

生产级别使用(分布式集群)

某一个结点启动一个server模式代理,如下

consul agent -server -bootstrap-expect=1 \
    -data-dir=/tmp/consul -node=agent-one -bind=valid extranet IP \
    -enable-script-checks=true -config-dir=/usr/local/etc/consul.d

查看集群成员

consul members

Node       Address         Status  Type    Build  Protocol  DC   Segment
agent-one  valid extranet IP:8301  alive   server  1.1.0  2         dc1  <all>

另一个结点启动一个client模式代理,如下

consul agent \
    -data-dir=/tmp/consul -node=agent-two -bind=139.129.5.228 \
    -enable-script-checks=true -config-dir=/usr/local/etc/consul.d

查看集群成员

consul members

Node       Address         Status  Type    Build  Protocol  DC   Segment
agent-two  139.129.5.228:8301  alive   server  1.1.0  2         dc1  <all>

加入Cluster

consul join 139.129.5.228
consul members

Node       Address         Status  Type    Build  Protocol  DC   Segment
agent-one  valid extranet IP:8301  alive   server  1.1.0  2         dc1  <all>
agent-two  139.129.5.228:8301  alive   server  1.1.0  2         dc1  <all>

集成node-consul

config.js

// 服务注册与发现
// https://github.com/silas/node-consul#catalog-node-services
  'serverR&D': {
    consulServer: {
      type: 'consul',
      host: '127.0.0.1',
      port: 8500,
      secure: false,
      ca: [],
      defaults: {
        token: ''
      },
      promisify: true
    },
    bizService: {
      name: 'defaultName',
      id: 'defaultId',
      address: '127.0.0.1',
      port: 1000,
      tags: [],
      meta: {
        version: '',
        description: '注册集群'
      },
      check: {
        http: '',
        // check间隔时间(ex: 15s)
        interval: '10s',
        // check超时时间(ex: 10s)
        timeout: '2s',
        // 处于临界状态后自动注销服务的超时时间
        deregistercriticalserviceafter: '30s',
        // 初始化状态值为成功
        status: 'passing',
        // 备注
        notes: '{"version":"111","microservice-port":1115}'
      }
    }
  }

server-register.js

/*
 * @Author: Cecil
 * @Last Modified by: Cecil
 * @Last Modified time: 2018-06-02 11:26:49
 * @Description 微服务注册方法
 */
const defaultConf = require('../config')['serverR&D']
const { ObjectDeepSet, isString } = require('../helper/utils')
const Consul = require('consul')
const { generateServiceName, generateCheckHttp } = require('../helper/consul')

// 注册服务

function register({ consulServer = {}, bizService = {} } = {}) {
  if (!bizService.name && isString(bizService.name)) throw new Error('name is invalid!')
  if (bizService.port !== +bizService.port) throw new Error('port is invalid!')
  if (!bizService.host && isString(bizService.host)) throw new Error('host is invalid!')
  if (!bizService.meta.$$version) throw new Error('meta.$$version is invalid!')
  if (!bizService.meta.$$microservicePort) throw new Error('meta.$$microservicePort is invalid!')
  const consul = Consul(ObjectDeepSet(defaultConf.consulServer, consulServer))
  const service = defaultConf.bizService
  service.name = generateServiceName(bizService.name)
  service.id = service.name
  service.address = bizService.host
  service.port = bizService.port
  service.check.http = generateCheckHttp(bizService.host, bizService.port)
  service.check.notes = JSON.stringify(bizService.meta)

  return new Promise((resolve, reject) => {
    consul.agent.service.list().then(services => {
      // 检查主机+端口是否已被占用
      Object.keys(services).some(key => {
        if (services[key].Address === service.address && services[key].Port === service.port) {
          throw new Error(`该服务集群endpoint[${service.address}, ${service.port}]已被占用!`)
        }
      })
      // 注册集群服务
      consul.agent.service.register(service).then(() => {
        logger.info(`${bizService.name}服务已注册`)
        resolve(services)
      }).catch(err => {
        console.log(err)
      })
    }).catch(err => {
      throw new Error(err)
    })
  })
}

module.exports = class ServerRegister {
  constructor() {
    this.register = register
  }
}

验证

保证runtime中存在consul和mongodb服务后,clone该仓库Demo,cd到工程根目录下,运行node src即可。

框架集成node-consul

server-register.js

/*
 * @Author: Cecil
 * @Last Modified by: Cecil
 * @Last Modified time: 2018-06-02 13:58:22
 * @Description 微服务注册方法
 */
const defaultConf = require('../config')['serverR&D']
const { ObjectDeepSet, isString } = require('../helper/utils')
const Consul = require('consul')
const { generateServiceName, generateCheckHttp } = require('../helper/consul')
const logger = new (require('./logger'))().generateLogger()

// 注册服务方法定义

function register({ consulServer = {}, bizService = {} } = {}) {
  if (!bizService.name && isString(bizService.name)) throw new Error('name is invalid!')
  if (bizService.port !== +bizService.port) throw new Error('port is invalid!')
  if (!bizService.host && isString(bizService.host)) throw new Error('host is invalid!')
  if (!bizService.meta.$$version) throw new Error('meta.$$version is invalid!')
  if (!bizService.meta.$$microservicePort) throw new Error('meta.$$microservicePort is invalid!')
  const consul = Consul(ObjectDeepSet(defaultConf.consulServer, consulServer))
  const service = defaultConf.bizService
  service.name = generateServiceName(bizService.name)
  service.id = service.name
  service.address = bizService.host
  service.port = bizService.port
  service.check.http = generateCheckHttp(bizService.host, bizService.port)
  service.check.notes = JSON.stringify(bizService.meta)

  return new Promise((resolve, reject) => {
    consul.agent.service.list().then(services => {
      // 检查主机+端口是否已被占用
      Object.keys(services).some(key => {
        if (services[key].Address === service.address && services[key].Port === service.port) {
          throw new Error(`该服务集群endpoint[${service.address}, ${service.port}]已被占用!`)
        }
      })
      // 注册集群服务
      consul.agent.service.register(service).then(() => {
        logger.info(`${bizService.name}服务注册成功`)
        resolve(services)
      }).catch(err => {
        console.log(err)
      })
    }).catch(err => {
      throw new Error(err)
    })
  })
}

module.exports = class ServerRegister {
  constructor() {
    this.register = register
  }
}

account-server/src/index.js

const vastify = require('vastify')
const version = require('../package.json').version
const microservicePort = 10015
const httpPort = 3333

// 注册服务
vastify.ServerRegister.register({
  bizService: {
    name: 'account-server',
    host: '127.0.0.1',
    port: httpPort,
    meta: {
      $$version: version,
      $$microservicePort: microservicePort
    }
  }
})

Mongodb持久化存储

  • 框架使用mongoose做mongoClient,当然你也可以选用原生nodejs mongoClient。

改造之前的user模块,偷个懒就不贴代码了,具体请查看Demo

结合seneca以及consul的路由服务中间件

microRouting.js

/*
 * @Author: Cecil
 * @Last Modified by: Cecil
 * @Last Modified time: 2018-06-02 16:22:02
 * @Description 微服务内部路由中间件,暂不支持自定义路由匹配策略
 */

'use strict'

const Consul = require('consul')
const defaultConf = require('../config')
const { ObjectDeepSet, isNumber } = require('../helper/utils')
const { getServiceNameByServiceKey, getServiceIdByServiceKey } = require('../helper/consul')
const logger = new (require('../tools/logger'))().generateLogger()
const { IPV4_REGEX } = require('../helper/regex')

let services = {}
let consul = null

/**
 * @author Cecil0o0
 * @description 同步consul服务中心的所有可用服务以及对应check并组装成对象以方便取值
 */
function syncCheckList () {
  return new Promise((resolve, reject) => {
    consul.agent.service.list().then(allServices => {
      if (Object.keys(allServices).length > 0) {
        services = allServices
        consul.agent.check.list().then(checks => {
          Object.keys(checks).forEach(key => {
            allServices[getServiceIdByServiceKey(key)]['check'] = checks[key]
          })
          resolve(services)
        }).catch(err => {
          throw new Error(err)
        })
      } else {
        const errmsg = '未发现可用服务'
        logger.warn(errmsg)
        reject(errmsg)
      }
    }).catch(err => {
      throw new Error(err)
    })
  })
}

function syncRoutingRule(senecaInstance = {}, services = {}) {
  Object.keys(services).forEach(key => {
    let service = services[key]
    let name = getServiceNameByServiceKey(key)
    let $$addr = service.Address
    let $$microservicePort = ''
    let $$version = ''
    try {
      let base = JSON.parse(service.check.Notes)
      $$microservicePort = base.$$microservicePort
      $$version = base.$$version
    } catch (e) {
      logger.warn(`服务名为${serviceName}。该服务check.Notes为非标准JSON格式,程序已忽略。请检查服务注册方式(请确保调用ServerRegister的register来注册服务)`)
    }

    if (IPV4_REGEX.test($$addr) && isNumber($$microservicePort)) {
      if (service.check.Status === 'passing') {
        senecaInstance.client({
          host: $$addr,
          port: $$microservicePort,
          pin: {
            $$version,
            $$target: name
          }
        })
      } else {
        logger.warn(`${$$target}@${$$version || '无'}服务处于critical,因此无法使用`)
      }
    } else {
      logger.warn(`主机(${$$addr})或微服务端口号(${$$microservicePort})有误,请检查`)
    }
  })
}


function startTimeInterval() {
  setInterval(syncCheckList, defaultConf.routing.servicesRefresh)
}

function microRouting(consulServer) {
  var self = this
  consul = Consul(ObjectDeepSet(defaultConf['serverR&D'].consulServer, consulServer))
  syncCheckList().then(services => {
    syncRoutingRule(self, services)
  })
}

module.exports = microRouting

在保证有consul与mongodb的runtime后,请结合这两个config-serveraccount-server Demo进行测试。

[未完待续....]

查看原文

赞 52 收藏 54 评论 7

司想君 发布了文章 · 2018-05-28

前端也要学系列:设计模式之装饰者模式

什么是装饰者模式

今天我们来讲另外一个非常实用的设计模式:装饰者模式。这个名字听上去有些莫名其妙,不着急,我们先来记住它的一个别名:包装器模式

我们记着这两个名字来开始今天的文章。

首先还是上《设计模式》一书中的经典定义:

  1. 动态地给一个对象添加一些额外的职责。
  2. 就增加功能来说,装饰者模式相比生成子类更为灵活。

我们来分析一下这个定义。

给对象添加一些新的职责,我们很容易想到创建子类来继承父类,然后在子类上增加额外的职责。

那什么是动态地呢?应该就是说这些新添加的职责在类一开始创建的时候我们并不知道,而是在使用过程根据需要而添加的。

相比生成子类更为灵活,这句话让装饰者模式和子类继承赤裸裸的刀兵相见了。没有对比就没有伤害,那我们就用例子来验证这句话。

传统面向对象的实现

我们假设你是以为已经走上人生巅峰的汽车生产商,你的公司生产各种用途的汽车,某一天一个客户下单了四种汽车,分别是家用轿车、SUV、旅行车和跑车。我们很轻松地像下面这样进行交付了。
var Car = function(){}

Car.prototype.start = function(){
    console.log("轰轰轰,启动正常!")
}

var Sedan = new car();// 小轿车
var Suv = new Car();// SUV
var Wagon=new Car();// 旅行车
var Roadster=new Car();// 跑车
//是不是又学会了几个英文单词?

过了几天客户找来了,说最近人们爱上了西藏自驾游,人们都希望能够选装一些方便越野和载物的功能,比如加装雪地胎、行李箱,升高底盘。

有经验的你满口答应下来,这个简单,于是你交付了下面的代码:

//SUV
Suv.prototype.changeTire = function(){
    console.log("我换了雪地胎");
} 
Suv.prototype.addHeight = function(){
     console.log("我升高了底盘");
}
Suv.prototype.addBox = function(){
    console.log("我安装了行李箱");
}
//Wagon
Wagon.prototype.changeTire = function(){
    console.log("我换了雪地胎");
} 
Wagon.prototype.addHeight = function(){
     console.log("我升高了底盘");
}
Wagon.prototype.addBox = function(){
    console.log("我安装了行李箱");
}
//Sedan
Sedan.prototype.changeTire = function(){
    console.log("我换了雪地胎");
} 
Sedan.prototype.addHeight = function(){
     console.log("我升高了底盘");
}
Sedan.prototype.addBox = function(){
    console.log("我安装了行李箱");
}

// 使用
var suv = new Suv();
suv.changeTire();
suv.addHeight();
suv.addBox();
suv.start();
...

你增加了多少种特性?3x3=9种。

你又问,我直接把这三个特性加在Car上不行吗?就不用这么麻烦了。

当然不行,因为我们还有一种车:Roadster跑车。

你能想象法拉利换了雪地胎背上行李箱升高底盘是个什么死样子吗?这么干的人肯定疯了。

如果我们把特性一股脑加在Car上,就避免不了这种情况的发生。

这个时候,就体现出子类继承的不灵活之处。

下面,装饰者模式就要正式登场了。

var Car=function (){}

Car.prototype.start=function(){
    console.log("轰轰轰,启动正常!")
}

// 创建装饰类(包装类)

var ChangeTireDec=function(car){
    this.car=car;
}
var AddHeightDec=function(car){
    this.car=car;
}
var AddBoxDec=function(car){
    this.car=car;
}

// 装饰类具有和Car同样的特性,只不过额外执行了一些其他的操作

ChangeTireDec.prototype.start=function(){
    console.log("我换了雪地胎");
    this.car.start();
}

AddHeightDec.prototype.start=function(car){
    console.log("我升高了底盘");
    this.car.start();
}
AddBoxDec.prototype.start=function(car){
    console.log("我安装了行李箱");
    this.car.start();
}

// 使用
var suv=new Suv();

suv=new ChangeTireDec(suv);
suv=new AddHeightDec(suv);
suv=new AddBoxDec(suv);

suv.start();

上面的代码你增加了几种特性?只有三种!而且不管你是给SUV还是Wagon还是Sedan加装,都不需要再增加特性的代码。

这,就是装饰者模式的优势所在。

现在我们再回过头来看看GoF的定义:

  1. 动态地给一个对象添加一些额外的职责。
  2. 就增加功能来说,装饰者模式相比生成子类更为灵活。

怎么样,是不是如同1+1=2一样简单了?现在你应该也明白了为什么装饰者模式又叫座包装器模式了。因为它将类的原有特性包装起来,添加其他的特性,就像一个箱子一样。而且实现过程中,还满足了封闭-开放原则。

JavaScript的实现

上面的例子中,我们是模拟了传统的面向对象语言来解释什么是装饰者模式。我们都知道,要动态改变JavaScript对象非常容易,可以向操作变量一个操作对象,我们再来改写下上面的例子,让它更javasripty

var car = {
    start: function(){
        console.log("轰轰轰,正常启动!");
    }
}

var ChangeTireDec = function(){
        console.log("我换了雪地胎");
}
var AddHeightDec = function(){
    console.log("我升高了底盘");
}
var AddBoxDec = function(){
     console.log("我安装了行李箱");
}

var start1 = car.start;
car.start=function(){
    ChangeTireDec();
    start1();
}

var start2=car.start;
car.start = function(){
    AddHeightDec();
    start2();
}

var start3=car.start();
car.start = function(){
    AddBoxDec();
    start3();
}

// 执行

car.start();

实际中的应用

从上面的例子我们可以看出来,我们不断的将car.start的引用赋值给临时变量,然后将原来的car.start指向新的对象--包含了原来对象的引用和新的特性的对象。这很好的保证了代码的开放-封闭原则,这是今天第二次提到这个原则了,就是对修改封闭,对新增开放。

特别当你要重构一个非常复杂的多人项目时,如果你不想因为修改了同事的一行代码而引起“蝴蝶效应”,那么将他的方法整个打包赋值然后用装饰者模式增加新的功能,是一种非常安全而且高效的做法。

下一步,我们可以愉快的去使用装饰者模式啦!


图片描述

查看原文

赞 2 收藏 8 评论 0

司想君 发布了文章 · 2018-05-24

前端也要学系列:设计模式之策略模式

做前端开发已经好几年了,对设计模式一直没有深入学习总结过。随着架构相关的工作越来越多,越来越能感觉到设计模式成为了我前进道路上的一个阻碍。所以从今天开始深入学习和总结经典的设计模式以及面向对象的几大原则。

今天第一天,首先来讲策略模式。

什么是策略模式?

GoF四兄弟的经典《设计模式》中,对策略模式的定义如下:

定义一系列的算法,把它们一个个封装起来,并且使它们可互相替换。

上边这句话,从字面来看很简单。但是如何在开发过程中去应用,仅凭一个定义依然是一头雾水。以笔者曾经做过的商户进销存系统为例:

某超市准备举行促销活动,市场人员经过调查分析制定了一些促销策略:
  1. 购物满100减10
  2. 购物满200减30
  3. 购物满300减50
  4. 。。。

收银软件的界面是这样的(简单示意):

图片描述

我们应该如何计算实际消费金额?

最初的实现是这样的:

//方便起见,我们把各个促销策略定义为枚举值:0,1,2...
var getActualTotal = function(onSaleType,originTotal){
    if(onSaleType===0){
        return originTotal-Math.floor(originTotal/100)*10
    }
    if(onSaleType===1){
        return originTotal-Math.floor(originTotal/200)*30
    }
    if(onSaleType===0){
        return originTotal-Math.floor(originTotal/300)*50
    }
}

getActualTotal(1,2680); //2208

上面这段代码很简单,而且缺点也很明显。随着我的满减策略逐渐增多,getActualTotal函数会越变越大,而且充满了if判断,稍一疏忽就容易弄错。

OK,有人说我很懒,虽然这样不够优雅但并不影响我的使用,毕竟满减策略再多也多不到哪去。
我只能说,需求永远不是程序员定的。。这时,市场人员说我们新版程序添加了会员功能,我们需要支持以下的促销策略:

会员促销策略:
  1. 会员充300返60,且首单打9折
  2. 会员充500返100,且首单打8折
  3. 会员充1000返300,且首单打7折
  4. ...

这个时候,如果你还在原先的getActualTotal函数中继续添加if判断,我想如果你的领导review你这段代码,可能会怀疑自己当初怎么把你招进来。。

OK,我们终于下定决心要重构促销策略的代码,我们可以这么做:

var vipPolicy_0=function(originTotal){
    return originTotal-Math.floor(originTotal/100)*10
}
var vipPolicy_1=function(originTotal){
    return originTotal-Math.floor(originTotal/200)*30
}
...
//会员充1000返300
var vipPolicy_10=function(account,originTotal){
    if(account===0){
        account+=1300;
        return originTotal*0.9
    }else{
        account+=1300;
        return originTotal;
    }
    return originTotal-Math.floor(originTotal/200)*30
}
...
var vipPolicy_n=function(){
    ...
}

var getActualTotal=function(onSaleType,originTotal,account){
    switch(onSaleType){
        case 0:
            return vipPolicy_0(originTotal);
        case 1:
            return vipPolicy_0(originTotal);
        ...
        case n:
            return ...
        default:
            return originTotal;
    }
}

好了,现在我们每种策略都有自己独立的空间了,看起来井井有条。但是还有两个问题没有解决:

  1. 随着促销策略的增加,getActualTotal的代码量依然会越来越大
  2. 系统缺乏弹性,如果需要增加一种策略,那么除了添加一个策略函数,还需要修改switch...case..语句

让我们再来回顾一下策略模式的定义:

定义一系列的算法,把它们一个个封装起来,并且使它们可互相替换

在我们的例子中,每种促销策略的实现方式是不一样的,但我们最终的目的都是为了求得实际金额。策略模式可以把我们对促销策略的算法一个个封装起来,并且使它们可互相替换而不影响我们对实际金额的求值,这正好是我们所需要的。

下面我们用策略模式来重构上面的代码:

var policies={
    "Type_0":function(originTotal){
        return originTotal-Math.floor(originTotal/100)*10 
    },
    "Type_1":function(originTotal){
        return originTotal-Math.floor(originTotal/200)*30 
    },
    ...
    "Type_n":function(originTotal){
        ... 
    }
}

var getActualTotal=function(onSaleType,originTotal,account){
    return policies["Type_"+onSaleType](originTotal,account)
}
//执行
getActualTotal(0,2680.00);//2208

分析上面的代码我们发现,不管促销策略如何增加,getActualTotal函数完全不需要再变化了。我们要做的,就是增加新策略的函数而已。

通过策略模式的代码,我们消除了让人反胃的大片条件分支语句,getActualTotal本身并没有计算能力,而是将计算全权委托给了策略函数。

由此我们可以总结出策略模式实现的要点:

  1. 将变化的算法封装成独立的策略函数,并负责具体的计算
  2. 委托函数,该函数接受客户请求,并将请求委托给某一个具体的策略函数

用一张UML图表示如下:
图片描述

怎么样?现在看到上面这张图是不是有了了然于胸的感觉?那就赶紧去试一试策略模式吧!


参考书籍:

  1. 《设计模式:可复用面向对象软件的基础》
  2. 《大话设计模式》
  3. 《Javascript设计模式与开发实践》
查看原文

赞 15 收藏 26 评论 6

认证与成就

  • 获得 170 次点赞
  • 获得 10 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-09-06
个人主页被 1.1k 人浏览