吴彦欣

吴彦欣 查看完整档案

珠海编辑广州大学华软软件学院  |  软件工程 编辑金山软件  |  前端工程师 编辑 wuyanxin.com 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

吴彦欣 收藏了文章 · 2020-10-22

你可能已经忽略的git commit规范

gitcommit.jpeg

引言

在日常的开发工作中,我们通常使用 git 来管理代码,当我们对代码进行某项改动后,都可以通过 git commit 来对代码进行提交。

git 规定提交时必须要写提交信息,作为改动说明,保存在 commit 历史中,方便回溯。规范的 log 不仅有助于他人 review, 还可以有效的输出 CHANGELOG,甚至对于项目的研发质量都有很大的提升。

但是在日常工作中,大多数同学对于 log 信息都是简单写写,没有很好的重视,这对于项目的管理和维护来说,无疑是不友好的。本篇文章主要是结合我自己的使用经验来和大家分享一下 git commit 的一些规范,让你的 log 不仅“好看”还“实用”。

为什么要规范 git commit

一直在说要规范 commit 格式,那为什么要这样做呢?

让我们先来看一个不太规范的 commit 记录:

看完什么感觉,写的是啥啊(内心 OS),这种 commit 信息对于想要从中获取有效信息的人来说无疑是一种致命的打击。

那我们来看一个社区里面比较流行的Angular规范的 commit 记录:

看完是不是一目了然呢?

上图中这种规范的 commit 信息首先提供了更多的历史信息,方便快速浏览。其次,可以过滤某些 commit(比如文档改动),便于快速查找信息。

既然说到了 Angular 团队的规范是目前社区比较流行的 commit 规范,那它具体是什么呢?下面让我们来具体深入了解下吧。

Angular 团队的 commit 规范

它的 message 格式如下:

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

分别对应 Commit message 的三个部分:HeaderBodyFooter

Header

Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。

  • type: 用于说明 commit 的类型。一般有以下几种:

    feat: 新增feature
    fix: 修复bug
    docs: 仅仅修改了文档,如readme.md
    style: 仅仅是对格式进行修改,如逗号、缩进、空格等。不改变代码逻辑。
    refactor: 代码重构,没有新增功能或修复bug
    perf: 优化相关,如提升性能、用户体验等。
    test: 测试用例,包括单元测试、集成测试。
    chore: 改变构建流程、或者增加依赖库、工具等。
    revert: 版本回滚
  • scope: 用于说明 commit 影响的范围,比如: views, component, utils, test...
  • subject: commit 目的的简短描述

Body

对本次 commit 修改内容的具体描述, 可以分为多行。如下所示:

# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
# initial commit

Footer

一些备注, 通常是 BREAKING CHANGE(当前代码与上一个版本不兼容) 或修复的 bug(关闭 Issue) 的链接。

简单介绍完上面的规范,我们下面来说一下commit.template,也就是 git 提交信息模板。

git 提交信息模板

如果你的团队对提交信息有格式要求,可以在系统上创建一个文件,并配置 git 把它作为默认的模板,这样可以更加容易地使提交信息遵循格式。

通过以下命令来配置提交信息模板:

git config commit.template   [模板文件名]    //这个命令只能设置当前分支的提交模板
git config  — —global commit.template   [模板文件名]    //这个命令能设置全局的提交模板,注意global前面是两杠

新建 .gitmessage.txt(模板文件) 内容可以如下:

# headr: <type>(<scope>): <subject>
# - type: feat, fix, docs, style, refactor, test, chore
# - scope: can be empty
# - subject: start with verb (such as 'change'), 50-character line
#
# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
#
# footer:
# - Include a link to the issue.
# - BREAKING CHANGE
#

看完上面这些,你会不会像我一样感觉配置下来挺麻烦的,配置一个适合自己和团队使用的近乎完美的 commit 规范看来也不是一件容易的事情。不过社区也为我们提供了一些辅助工具来帮助进行提交,下面来简单介绍一下这些工具。

commitizen(cz-cli)

commitizen是一款可以交互式建立提交信息的工具。它帮助我们从 type 开始一步步建立提交信息,具体效果如图所示:

  • 首先通过上下键控制指向你想要的 type 类型,分别对应有上面提到的featfixdocsperf等:

  • 然后会让你选择本次提交影响到的文件:

  • 后面会让你分别写一个简短的和详细的提交描述:

  • 最后会让你去判断本次提交是否是BREAKING CHANGE或者有关联已开启的issue:

看完上面的 commitizen 的整个流程,下面让我们来看下如何来安装。

  • 全局环境下安装:

    commitizen 根据不同的adapter配置 commit message。例如,要使用 Angular 的 commit message 格式,可以安装cz-conventional-changelog
    # 需要同时安装commitizen和cz-conventional-changelog,后者是adapter
    $ npm install -g commitizen cz-conventional-changelog
    # 配置安装的adapter
    $ echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
    # 使用
    $ git cz
    
  • 本地项目安装:

    # 安装commitizen
    $ npm install --save-dev commitizen
    # 接下来安装适配器
    # for npm >= 5.2
    $ npx commitizen init cz-conventional-changelog --save-dev --save-exact
    # for npm < 5.2
    $ ./node_modules/.bin/commitizen init cz-conventional-changelog --save-dev --save-exact
    
    // package.json script字段中添加commit命令
    "scripts": {
       "commit": "git-cz"
    }
    // use
    $ npm run commit

commitlint

commitlint是一个提交验证工具。原理是可以在实际的 git commit 提交到远程仓库之前使用 git 钩子来验证信息。提交不符合规则的信息将会被阻止提交到远程仓库。

先来看一下演示:

对于 Conventional Commits 规范,社区已经整理好了 @commitlint/config-conventional 包,我们只需要安装并启用它就可以了。

首先安装 commitlint 以及 conventional 规范:

npm install --save-dev @commitlint/cli @commitlint/config-conventional

接着在 package.json 中配置 commitlint 脚本:

"commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
当然如果你想单独对 commitlint 进行配置的话,需要建立校验文件 commitlint.config.js,不然会校验失败

为了可以在每次 commit 时执行 commitlint 来 检查我们输入的 message,我们还需要用到一个工具 —— husky

husky 是一个增强的 git hook 工具。可以在 git hook 的各个阶段执行我们在 package.json 中配置好的 npm script。

首先安装 husky:

npm install --save-dev husky

接着在 package.json 中配置 commitmsg 脚本:

"husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
 },

到这里,commitlint就配置完成了~

gitmoji-cli

平时与朋友聊天时,我们一定会用到表情包,比如。表情包的出现让我们与朋友之间的沟通变得更加有趣。如果能在 git 提交 commit 时用到表情包,岂不是使每次的 commit 能够更加直观,维护起来也更加方便。

gitmoji就是可以实现这种功能的插件,先让我们来感受一下

有没有感觉很 cool~~

其实gitmoji的使用是很简单的:

# 安装
npm i -g gitmoji-cli
# 使用
git commit -m ':bug: 问题fix'

我们来看一下官方的示例吧:

是不是跃跃欲试了呢?

gitmoji项目地址

gitmoji使用示例

看完本文,是不是感觉对于git commit message又有了新的认识呢?去在你的项目中运用这些吧,让你的commit更加规范的同时,也不要忘了给你的log加上emoji哦!

最后附上一个之前项目针对git commit配置的package.json,作为参考:

{
  "name": "ts-axios",
  "version": "0.0.0",
  "description": "",
  "keywords": [],
  "main": "dist/ts-axios.umd.js",
  "module": "dist/ts-axios.es5.js",
  "typings": "dist/types/ts-axios.d.ts",
  "files": [
    "dist"
  ],
  "author": "fengshuan <1263215592@qq.com>",
  "repository": {
    "type": "git",
    "url": ""
  },
  "license": "MIT",
  "engines": {
    "node": ">=6.0.0"
  },
  "scripts": {
    "dev": "node examples/server.js",
    "lint": "tslint  --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
    "prebuild": "rimraf dist",
    "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src",
    "start": "rollup -c rollup.config.ts -w",
    "test": "jest --coverage",
    "test:watch": "jest --coverage --watch",
    "test:prod": "npm run lint && npm run test -- --no-cache",
    "deploy-docs": "ts-node tools/gh-pages-publish",
    "report-coverage": "cat ./coverage/lcov.info | coveralls",
    "commit": "git-cz",
    "semantic-release": "semantic-release",
    "semantic-release-prepare": "ts-node tools/semantic-release-prepare",
    "precommit": "lint-staged",
    "travis-deploy-once": "travis-deploy-once"
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "{src,test}/**/*.ts": [
      "prettier --write",
      "git add"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-conventional-changelog"
    }
  },
  "jest": {
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "testEnvironment": "node",
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ],
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 90,
        "functions": 95,
        "lines": 95,
        "statements": 95
      }
    },
    "collectCoverageFrom": [
      "src/*.{js,ts}"
    ]
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  },
  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
  "devDependencies": {
    "@commitlint/cli": "^7.1.2",
    "@commitlint/config-conventional": "^7.1.2",
    "@types/jest": "^23.3.2",
    "@types/node": "^10.11.0",
    "body-parser": "^1.19.0",
    "colors": "^1.3.2",
    "commitizen": "^3.0.0",
    "coveralls": "^3.0.2",
    "cross-env": "^5.2.0",
    "cz-conventional-changelog": "^2.1.0",
    "express": "^4.17.1",
    "husky": "^1.0.1",
    "jest": "^23.6.0",
    "jest-config": "^23.6.0",
    "lint-staged": "^8.0.0",
    "lodash.camelcase": "^4.3.0",
    "prettier": "^1.14.3",
    "prompt": "^1.0.0",
    "replace-in-file": "^3.4.2",
    "rimraf": "^2.6.2",
    "rollup": "^0.67.0",
    "rollup-plugin-commonjs": "^9.1.8",
    "rollup-plugin-json": "^3.1.0",
    "rollup-plugin-node-resolve": "^3.4.0",
    "rollup-plugin-sourcemaps": "^0.4.2",
    "rollup-plugin-typescript2": "^0.18.0",
    "semantic-release": "^15.9.16",
    "shelljs": "^0.8.3",
    "travis-deploy-once": "^5.0.9",
    "ts-jest": "^23.10.2",
    "ts-loader": "^6.1.1",
    "ts-node": "^7.0.1",
    "tslint": "^5.11.0",
    "tslint-config-prettier": "^1.15.0",
    "tslint-config-standard": "^8.0.1",
    "tslint-loader": "^3.5.4",
    "typedoc": "^0.12.0",
    "typescript": "^3.0.3",
    "webpack": "^4.40.2",
    "webpack-dev-middleware": "^3.7.1",
    "webpack-hot-middleware": "^2.25.0"
  }
}

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!

image

查看原文

吴彦欣 赞了文章 · 2020-10-22

你可能已经忽略的git commit规范

gitcommit.jpeg

引言

在日常的开发工作中,我们通常使用 git 来管理代码,当我们对代码进行某项改动后,都可以通过 git commit 来对代码进行提交。

git 规定提交时必须要写提交信息,作为改动说明,保存在 commit 历史中,方便回溯。规范的 log 不仅有助于他人 review, 还可以有效的输出 CHANGELOG,甚至对于项目的研发质量都有很大的提升。

但是在日常工作中,大多数同学对于 log 信息都是简单写写,没有很好的重视,这对于项目的管理和维护来说,无疑是不友好的。本篇文章主要是结合我自己的使用经验来和大家分享一下 git commit 的一些规范,让你的 log 不仅“好看”还“实用”。

为什么要规范 git commit

一直在说要规范 commit 格式,那为什么要这样做呢?

让我们先来看一个不太规范的 commit 记录:

看完什么感觉,写的是啥啊(内心 OS),这种 commit 信息对于想要从中获取有效信息的人来说无疑是一种致命的打击。

那我们来看一个社区里面比较流行的Angular规范的 commit 记录:

看完是不是一目了然呢?

上图中这种规范的 commit 信息首先提供了更多的历史信息,方便快速浏览。其次,可以过滤某些 commit(比如文档改动),便于快速查找信息。

既然说到了 Angular 团队的规范是目前社区比较流行的 commit 规范,那它具体是什么呢?下面让我们来具体深入了解下吧。

Angular 团队的 commit 规范

它的 message 格式如下:

<type>(<scope>): <subject>
// 空一行
<body>
// 空一行
<footer>

分别对应 Commit message 的三个部分:HeaderBodyFooter

Header

Header 部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。

  • type: 用于说明 commit 的类型。一般有以下几种:

    feat: 新增feature
    fix: 修复bug
    docs: 仅仅修改了文档,如readme.md
    style: 仅仅是对格式进行修改,如逗号、缩进、空格等。不改变代码逻辑。
    refactor: 代码重构,没有新增功能或修复bug
    perf: 优化相关,如提升性能、用户体验等。
    test: 测试用例,包括单元测试、集成测试。
    chore: 改变构建流程、或者增加依赖库、工具等。
    revert: 版本回滚
  • scope: 用于说明 commit 影响的范围,比如: views, component, utils, test...
  • subject: commit 目的的简短描述

Body

对本次 commit 修改内容的具体描述, 可以分为多行。如下所示:

# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
# initial commit

Footer

一些备注, 通常是 BREAKING CHANGE(当前代码与上一个版本不兼容) 或修复的 bug(关闭 Issue) 的链接。

简单介绍完上面的规范,我们下面来说一下commit.template,也就是 git 提交信息模板。

git 提交信息模板

如果你的团队对提交信息有格式要求,可以在系统上创建一个文件,并配置 git 把它作为默认的模板,这样可以更加容易地使提交信息遵循格式。

通过以下命令来配置提交信息模板:

git config commit.template   [模板文件名]    //这个命令只能设置当前分支的提交模板
git config  — —global commit.template   [模板文件名]    //这个命令能设置全局的提交模板,注意global前面是两杠

新建 .gitmessage.txt(模板文件) 内容可以如下:

# headr: <type>(<scope>): <subject>
# - type: feat, fix, docs, style, refactor, test, chore
# - scope: can be empty
# - subject: start with verb (such as 'change'), 50-character line
#
# body: 72-character wrapped. This should answer:
# * Why was this change necessary?
# * How does it address the problem?
# * Are there any side effects?
#
# footer:
# - Include a link to the issue.
# - BREAKING CHANGE
#

看完上面这些,你会不会像我一样感觉配置下来挺麻烦的,配置一个适合自己和团队使用的近乎完美的 commit 规范看来也不是一件容易的事情。不过社区也为我们提供了一些辅助工具来帮助进行提交,下面来简单介绍一下这些工具。

commitizen(cz-cli)

commitizen是一款可以交互式建立提交信息的工具。它帮助我们从 type 开始一步步建立提交信息,具体效果如图所示:

  • 首先通过上下键控制指向你想要的 type 类型,分别对应有上面提到的featfixdocsperf等:

  • 然后会让你选择本次提交影响到的文件:

  • 后面会让你分别写一个简短的和详细的提交描述:

  • 最后会让你去判断本次提交是否是BREAKING CHANGE或者有关联已开启的issue:

看完上面的 commitizen 的整个流程,下面让我们来看下如何来安装。

  • 全局环境下安装:

    commitizen 根据不同的adapter配置 commit message。例如,要使用 Angular 的 commit message 格式,可以安装cz-conventional-changelog
    # 需要同时安装commitizen和cz-conventional-changelog,后者是adapter
    $ npm install -g commitizen cz-conventional-changelog
    # 配置安装的adapter
    $ echo '{ "path": "cz-conventional-changelog" }' > ~/.czrc
    # 使用
    $ git cz
    
  • 本地项目安装:

    # 安装commitizen
    $ npm install --save-dev commitizen
    # 接下来安装适配器
    # for npm >= 5.2
    $ npx commitizen init cz-conventional-changelog --save-dev --save-exact
    # for npm < 5.2
    $ ./node_modules/.bin/commitizen init cz-conventional-changelog --save-dev --save-exact
    
    // package.json script字段中添加commit命令
    "scripts": {
       "commit": "git-cz"
    }
    // use
    $ npm run commit

commitlint

commitlint是一个提交验证工具。原理是可以在实际的 git commit 提交到远程仓库之前使用 git 钩子来验证信息。提交不符合规则的信息将会被阻止提交到远程仓库。

先来看一下演示:

对于 Conventional Commits 规范,社区已经整理好了 @commitlint/config-conventional 包,我们只需要安装并启用它就可以了。

首先安装 commitlint 以及 conventional 规范:

npm install --save-dev @commitlint/cli @commitlint/config-conventional

接着在 package.json 中配置 commitlint 脚本:

"commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
当然如果你想单独对 commitlint 进行配置的话,需要建立校验文件 commitlint.config.js,不然会校验失败

为了可以在每次 commit 时执行 commitlint 来 检查我们输入的 message,我们还需要用到一个工具 —— husky

husky 是一个增强的 git hook 工具。可以在 git hook 的各个阶段执行我们在 package.json 中配置好的 npm script。

首先安装 husky:

npm install --save-dev husky

接着在 package.json 中配置 commitmsg 脚本:

"husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
 },

到这里,commitlint就配置完成了~

gitmoji-cli

平时与朋友聊天时,我们一定会用到表情包,比如。表情包的出现让我们与朋友之间的沟通变得更加有趣。如果能在 git 提交 commit 时用到表情包,岂不是使每次的 commit 能够更加直观,维护起来也更加方便。

gitmoji就是可以实现这种功能的插件,先让我们来感受一下

有没有感觉很 cool~~

其实gitmoji的使用是很简单的:

# 安装
npm i -g gitmoji-cli
# 使用
git commit -m ':bug: 问题fix'

我们来看一下官方的示例吧:

是不是跃跃欲试了呢?

gitmoji项目地址

gitmoji使用示例

看完本文,是不是感觉对于git commit message又有了新的认识呢?去在你的项目中运用这些吧,让你的commit更加规范的同时,也不要忘了给你的log加上emoji哦!

最后附上一个之前项目针对git commit配置的package.json,作为参考:

{
  "name": "ts-axios",
  "version": "0.0.0",
  "description": "",
  "keywords": [],
  "main": "dist/ts-axios.umd.js",
  "module": "dist/ts-axios.es5.js",
  "typings": "dist/types/ts-axios.d.ts",
  "files": [
    "dist"
  ],
  "author": "fengshuan <1263215592@qq.com>",
  "repository": {
    "type": "git",
    "url": ""
  },
  "license": "MIT",
  "engines": {
    "node": ">=6.0.0"
  },
  "scripts": {
    "dev": "node examples/server.js",
    "lint": "tslint  --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'",
    "prebuild": "rimraf dist",
    "build": "tsc --module commonjs && rollup -c rollup.config.ts && typedoc --out docs --target es6 --theme minimal --mode file src",
    "start": "rollup -c rollup.config.ts -w",
    "test": "jest --coverage",
    "test:watch": "jest --coverage --watch",
    "test:prod": "npm run lint && npm run test -- --no-cache",
    "deploy-docs": "ts-node tools/gh-pages-publish",
    "report-coverage": "cat ./coverage/lcov.info | coveralls",
    "commit": "git-cz",
    "semantic-release": "semantic-release",
    "semantic-release-prepare": "ts-node tools/semantic-release-prepare",
    "precommit": "lint-staged",
    "travis-deploy-once": "travis-deploy-once"
  },
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "{src,test}/**/*.ts": [
      "prettier --write",
      "git add"
    ]
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-conventional-changelog"
    }
  },
  "jest": {
    "transform": {
      ".(ts|tsx)": "ts-jest"
    },
    "testEnvironment": "node",
    "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js"
    ],
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 90,
        "functions": 95,
        "lines": 95,
        "statements": 95
      }
    },
    "collectCoverageFrom": [
      "src/*.{js,ts}"
    ]
  },
  "prettier": {
    "semi": false,
    "singleQuote": true
  },
  "commitlint": {
    "extends": [
      "@commitlint/config-conventional"
    ]
  },
  "devDependencies": {
    "@commitlint/cli": "^7.1.2",
    "@commitlint/config-conventional": "^7.1.2",
    "@types/jest": "^23.3.2",
    "@types/node": "^10.11.0",
    "body-parser": "^1.19.0",
    "colors": "^1.3.2",
    "commitizen": "^3.0.0",
    "coveralls": "^3.0.2",
    "cross-env": "^5.2.0",
    "cz-conventional-changelog": "^2.1.0",
    "express": "^4.17.1",
    "husky": "^1.0.1",
    "jest": "^23.6.0",
    "jest-config": "^23.6.0",
    "lint-staged": "^8.0.0",
    "lodash.camelcase": "^4.3.0",
    "prettier": "^1.14.3",
    "prompt": "^1.0.0",
    "replace-in-file": "^3.4.2",
    "rimraf": "^2.6.2",
    "rollup": "^0.67.0",
    "rollup-plugin-commonjs": "^9.1.8",
    "rollup-plugin-json": "^3.1.0",
    "rollup-plugin-node-resolve": "^3.4.0",
    "rollup-plugin-sourcemaps": "^0.4.2",
    "rollup-plugin-typescript2": "^0.18.0",
    "semantic-release": "^15.9.16",
    "shelljs": "^0.8.3",
    "travis-deploy-once": "^5.0.9",
    "ts-jest": "^23.10.2",
    "ts-loader": "^6.1.1",
    "ts-node": "^7.0.1",
    "tslint": "^5.11.0",
    "tslint-config-prettier": "^1.15.0",
    "tslint-config-standard": "^8.0.1",
    "tslint-loader": "^3.5.4",
    "typedoc": "^0.12.0",
    "typescript": "^3.0.3",
    "webpack": "^4.40.2",
    "webpack-dev-middleware": "^3.7.1",
    "webpack-hot-middleware": "^2.25.0"
  }
}

最后

你可以关注我的同名公众号【前端森林】,这里我会定期发一些大前端相关的前沿文章和日常开发过程中的实战总结。当然,我也是开源社区的积极贡献者,github地址https://github.com/Cosen95,欢迎star!!!

image

查看原文

赞 32 收藏 25 评论 2

吴彦欣 收藏了文章 · 2020-04-14

领域驱动设计(DDD)实践之路(二):事件驱动与CQRS

本文首发于 vivo互联网技术 微信公众号 
链接: https://mp.weixin.qq.com/s/Z3...
作者:wenbo zhang

【领域驱动设计实践之路】系列往期精彩文章:

领域驱动设计(DDD)实践之路(一)》 主要讲述了战略层面的DDD原则。

这是“领域驱动设计实践之路”系列的第二篇文章,分析了如何应用事件来分离软件核心复杂度。探究CQRS为什么广泛应用于DDD项目中,以及如何落地实现CQRS框架。当然我们也要警惕一些失败的教训,利弊分析以后再去抉择正确的应对之道。

一、前言:从物流详情开始

大家对物流跟踪都不陌生,它详细记录了在什么时间发生了什么,并且数据作为重要凭证是不可变的。我理解其背后的价值有这么几个方面:业务方可以管控每个子过程、知道目前所处的环节;另一方面,当需要追溯时候仅仅通过每一步的记录就可以回放整个历史过程。

我在之前的文章中提出过“软件项目也是人类社会生产关系的范畴,只不过我们所创造的劳动成果看不见摸不着而已”。所以我们可以借鉴物流跟踪的思路来开发软件项目,把复杂过程拆解为一个个步骤、子过程、状态,这和我们事件划分是一致的,这就是事件驱动的典型案例。

二、领域事件

领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。

领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。

比如在前述的跨境物流例子中,货品达到保税仓以后需要分派工作人员进行分拣分包,那么“货品已到达保税仓”便是一个领域事件。

首先,从业务逻辑来说该事件关系到整个流程的成功或者失败;同时又将触发后续子流程;而对于业务方来说,该事件也是一个标志性的里程碑,代表自己的货品就快配送到自己手中。

所以通常来说,一个领域事件具有以下几个特征:较高的业务价值,有助于形成完整的业务闭环,将导致进一步的业务操作。这里还要强调一点,领域事件具有明确的边界。

比如:如果你建模的是餐厅的结账系统,那么此时的“客户已到达”便不是你关心的重点,因为你不可能在客户到达时就立即向对方要钱,而“客户已下单”才是对结账系统有用的事件。

1、建模领域事件

在建模领域事件时,我们应该根据限界上下文中的通用语言来命名事件及属性。如果事件由聚合上的命令操作产生,那么我们通常根据该操作方法的名字来命名领域事件。

对于上面的例子“货品已到达保税仓”,我们将发布与之对应的领域事件

GoodsArrivedBondedWarehouseEvent(当然在明确的界限上下文中也可以去掉聚合的名字,直接建模为ArrivedBondedWarehouseEvent,这都是命名方面的习惯)。

事件的名字表明了聚合上的命令方法在执行成功之后所发生的事情,换句话说待定项以及不确定的状态是不能作为领域事件的。

一个行之有效的方法是画出当前业务的状态流转图,包含前置操作以及引起的状态变更,这里表达的是已经变更完成的状态所以我们不用过去时态表示,比如删除或者取消,即代表已经删除或者已经取消。

然后对于其中的节点进行事件建模。如下图是文件云端存储的业务,我们分别对预上传、上传完成确认、删除等环节建模“过去时”事件,PreUploadedEvent、ConfirmUploadedEvent、RemovedEvent。

2、领域事件代码解读

package domain.event;

import java.util.Date;
import java.util.UUID;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */
public class DomainEvent {

    /**
     * 领域事件还包含了唯一ID,
     * 但是该ID并不是实体(Entity)层面的ID概念,
     * 而是主要用于事件追溯和日志。
     * 如果是数据库存储,该字段通常为唯一索引。
     */
    private final String id;

    /**
     * 创建时间用于追溯,另一方面不管使用了
     * 哪种事件存储都有可能遇到事件延迟,
     * 我们通过创建时间能够确保其发生顺序。
     */
    private final Date occurredOn;

    public DomainEvent() {
        this.id = String.valueOf(UUID.randomUUID());
        this.occurredOn = new Date();
    }
}

在创建领域事件时,需要注意2点:

  • 领域事件本身应该是不变的(Immutable);
  • 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。例如,在创建订单时可以携带订单的基本信息,而对于用户更新订单收货地址事件AddressUpdatedEvent事件,只需要包含订单、用户以及新的地址等信息即可。
public class AddressUpdatedEvent extends DomainEvent {
    //通过userId+orderId来校验订单的合法性;
    private String userId; 
    private String orderId;
    //新的地址
    private Address address;
    //略去具体业务逻辑
}

3、领域事件的存储

事件的不可变性与可追溯性都决定了其必须要持久化的原则,我们来看看常见的几种方案。

3.1单独的EventStore

有的业务场景中会创建一个单独的事件存储中心,可能是Mysql、Redis、Mongo、甚至文件存储等。这里以Mysql举例,business_code、event_code用来区分不同业务的不同事件,具体的命名规则可以根据实际需要。

这里需要注意该数据源与业务数据源不一致的场景,我们要确保当业务数据更新以后事件能够准确无误的记录下来,实践中尽量避免使用分布式事务,或者尽量避免其跨库的场景,否则你就得想想如何补偿了。千万要避免,用户更新了收货地址,但是AddressUpdatedEvent事件保存失败。

总的原则就是对分布式事务Say No,无论如何,我相信方法总比问题多,在实践中我们总可以想到解决方案,区别在于该方案是否简洁、是否做到了解耦。

# 考虑是否需要分表,事件存储建议逻辑简单
CREATE TABLE `event_store` (
  `event_id` int(11) NOT NULL auto increment,
  `event_code` varchar(32) NOT NULL,
  `event_name` varchar(64) NOT NULL,
  `event_body` varchar(4096) NOT NULL,
  `occurred_on` datetime NOT NULL,
  `business_code` varchar(128) NOT NULL,
  UNIQUE KEY (`event id`)
) ENGINE=InnoDB COMMENT '事件存储表';

3.2 与业务数据一起存储

在分布式架构中,每个模块都做的相对比较小,准确的说是“自治”。如果当前业务数据量较小,可以将事件与业务数据一起存储,用相关标识区分是真实的业务数据还是事件记录;或者在当前业务数据库中建立该业务自己的事件存储,但是要考虑到事件存储的量级必然大于真实的业务数据,考虑是否需要分表。

这种方案的优势:数据自治;避免分布式事务;不需要额外的事件存储中心。当然其劣势就是不能复用。

4、领域事件如何发布

4.1 由领域聚合发送领域事件

/*
* 一个关于比赛的充血模型例子
* 贫血模型会构造一个MatchService,我们这里通过模型来触发相应的事件
* 本例中略去了具体的业务细节
*/
public class Match {
    public void start() {
        //构造Event....
        MatchEvent matchStartedEvent = new MatchStartedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchStartedEvent);
    }

    public void finish() {
        //构造Event....
        MatchEvent matchFinishedEvent = new MatchFinishedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchFinishedEvent);
    }

    //略去Match对象基本属性
}

4.2 事件总线VS消息中间件

微服务内的领域事件可以通过事件总线或利用应用服务实现不同聚合之间的业务协同。即微服务内发生领域事件时,由于大部分事件的集成发生在同一个线程内,不一定需要引入消息中间件。但一个事件如果同时更新多个聚合数据,按照 DDD“一个事务只更新一个聚合根”的原则,可以考虑引入消息中间件,通过异步化的方式,对微服务内不同的聚合根采用不同的事务

三、Saga分布式事务

1、Saga概要

我们看看如何使用 Saga 模式维护数据一致性?

Saga 是一种在微服务架构中维护数据一致性的机制,它可以避免分布式事务所带来的问题。

一个 Saga 表示需要更新的多个服务中的一个,即Saga由一连串的本地事务组成。每一个本地事务负责更新它所在服务的私有数据库,这些操作仍旧依赖于我们所熟悉的ACID事务框架和函数库。

模式:Saga

通过使用异步消息来协调一系列本地事务,从而维护多个服务之间的数据一致性。

请参阅(强烈建议):https://microservices.io/patterns/data/saga.html

Saga与TCC相比少了一步Try的操作,TCC无论最终事务成功失败都需要与事务参与方交互两次。而Saga在事务成功的情况下只需要与事务参与方交互一次, 如果事务失败,需要额外进行补偿回滚。

  • 每个Saga由一系列sub-transaction Ti 组成;
  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果;

可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

  • success:T1, T2, T3, ..., Tn ;
  • failure:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n;

所以我们可以看到Saga的撤销十分关键,可以说使用Saga的难点就在于如何设计你的回滚策略。

2、Saga实现

通过上面的例子我们对Saga有了初步的体感,现在来深入探讨下如何实现。当通过系统命令启动Saga时,协调逻辑必须选择并通知第一个Saga参与方执行本地事务。一旦该事务完成,Saga协调选择并调用下一个Saga参与方。

这个过程一直持续到Saga执行完所有步骤。如果任何本地事务失败,则 Saga必须以相反的顺序执行补偿事务。以下几种不同的方法可用来构建Saga的协调逻辑。

2.1 协同式(choreography)

把 Saga 的决策和执行顺序逻辑分布在 Saga的每一个参与方中,它们通过交换事件的方式来进行沟通。

( 引用于《微服务架构设计模式》相关章节)

  1. Order服务创建一个Order并发布OrderCreated事件。
  2. Consumer服务消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
  3. Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
  4. Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
  5. Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡授权失败事件。
  6. Kitchen服务使用信用卡授权失败事件并将故障单的状态更改为REJECTED。
  7. 订单服务消费信用卡授权失败事件,并将订单状态更改为已拒绝。

2.2 编排式(orchestration)

把Saga的决策和执行顺序逻辑集中在一个Saga编排器类中。Saga 编排器发出命令式消息给各个 Saga 参与方,指示这些参与方服务完成具体操作(本地事务)。类似于一个状态机,当参与方服务完成操作以后会给编排器发送一个状态指令,以决定下一步做什么。

( 引用于《微服务架构设计模式》相关章节)

我们来分析一下执行流程

  1. Order Service首先创建一个Order和一个创建订单控制器。之后,路径的流程如下:
  2. Saga orchestrator向Consumer Service发送Verify Consumer命令。
  3. Consumer Service回复Consumer Verified消息。
  4. Saga orchestrator向Kitchen Service发送Create Ticket命令。
  5. Kitchen Service回复Ticket Created消息。
  6. Saga协调器向Accounting Service发送授权卡消息。
  7. Accounting服务部门使用卡片授权消息回复。
  8. Saga orchestrator向Kitchen Service发送Approve Ticket命令。
  9. Saga orchestrator向订单服务发送批准订单命令。

2.3 补偿策略

之前的描述中我们说过Saga最重要的是如何处理异常,状态机还定义了许多异常状态。如上面的6就会发生失败,触发AuthorizeCardFailure,此时我们就要结束订单并把之前提交的事务进行回滚。这里面要区分哪些是校验性事务、哪些是需要补偿的事务。

 一个Saga由三种不同类型的事务组成:可补偿性事务(可以回滚,因此有一个补偿事务);关键性事务(这是 Saga的成败关键点,比如4账户代扣);以及可重复性事务,它不需要回滚并保证能够完成(比如6更新状态)。

在Create Order Saga 中,createOrder()、createTicket()步骤是可补偿性事务且具有撤销其更新的补偿事务。

verifyConsumerDetails()事务是只读的,因此不需要补偿事务。authorizeCreditCard()事务是这个 Saga的关键性事务。如果消费者的信用卡可以授权,那么这个Saga保证完成。approveTicket()和approveRestaurantOrder()步骤是在关键性事务之后的可重复性事务。

认真拆解每个步骤、然后评估其补偿策略尤为重要,正如你看到的,每种类型的事务在对策中扮演着不同的角色。

四、CQRS

前面讲述了事件的概念,又分析了Saga如何解决复杂事务,现在我们来看看CQRS为什么在DDD中广泛被采用。除了读写分离的特征以外,我们用事件驱动的方式来实践Command逻辑能有效降低业务的复杂度。

当你明白如何建模事件、如何规避复杂事务,明白什么时候用消息中间件、什么时候采用事件总线,才能理解为什么是CQRS、怎么正确应用。

( 图片来源于网络)

下面是我们项目中的设计,这里为什么会出现Read/Write Service,是为了封装调用,service内部是基于聚合发送事件。因为我发现在实际项目中,很多人都会第一时间问我要XXXService而不是XXX模型,所以在DDD没有完全普及的项目中建议大家采取这种居中策略。这也符合咱们的解耦,对方依赖我的抽象能力,然而我内部是基于DDD还是传统的流程代码对其是无关透明的。

我们先来看看事件以及处理器的时序关系。

这里还是以文件云端存储业务为例,下面是一些处理器的核心代码。注释行是对代码功能、用法以及扩展方面的解读,请认真阅读。

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @Description: 事件注册逻辑
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DomainRegistry {

    private Map<String, List<DomainEventHandler>> handlerMap =
        new HashMap<String, List<DomainEventHandler>>();

    private static DomainRegistry instance;

    private DomainRegistry() {
    }

    public static DomainRegistry getInstance() {
        if (instance == null) {
            instance = new DomainRegistry();
        }
        return instance;
    }

    public Map<String, List<DomainEventHandler>> getHandlerMap() {
        return handlerMap;
    }

    public List<DomainEventHandler> find(String name) {
        if (name == null) {
            return null;
        }
        return handlerMap.get(name);
    }

    //事件注册与维护,register分多少个场景根据业务拆分,
    //这里是业务流的核心。如果多个事件需要维护前后依赖关系,
    //可以维护一个priority逻辑
    public void register(Class<? extends DomainEvent> domainEvent,
                         DomainEventHandler handler) {
        if (domainEvent == null) {
            return;
        }
        if (handlerMap.get(domainEvent.getName()) == null) {
            handlerMap.put(domainEvent.getName(), new ArrayList<DomainEventHandler>());
        }
        handlerMap.get(domainEvent.getName()).add(handler);
        //按照优先级进行事件处理器排序
        。。。
    }
}

文件上传完毕事件的例子。

package domain.handler.event;

import domain.DomainRegistry;
import domain.StateDispatcher;
import domain.entity.meta.MetaActionEnums;
import domain.event.DomainEvent;
import domain.event.MetaEvent;
import domain.repository.meta.MetaRepository;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

/**
 * @Description:一个事件操作的处理器
 * 我们混合使用了Saga的两种模式,外层事件交互;
 * 对于单个复杂的事件内部采取状态流转实现。
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

@Component
public class MetaConfirmUploadedHandler implements DomainEventHandler {

    @Resource
    private MetaRepository metaRepository;

    public void handle(DomainEvent event) {
        //1.我们在当前的上下文中定义个ThreadLocal变量
        //用于存放事件影响的聚合根信息(线程共享)

        //2.当然如果有需要额外的信息,可以基于event所
        //携带的信息构造Specification从repository获取
        // 代码示例
        // metaRepository.queryBySpecification(SpecificationFactory.build(event));

        DomainEvent domainEvent = metaRepository.load();

        //此处是我们的逻辑
        。。。。

        //对于单个操作比较复杂的,可以使用状态流转进一步拆分
        domainEvent.setStatus(nextState);
        //在事件触发之后,仍需要一个状态跟踪器来解决大事务问题
        //Saga编排式
        StateDispatcher.dispatch();
    }

    @PostConstruct
    public void autoRegister() {
        //此处可以更加细分,注册在哪一类场景中,这也是事件驱动的强大、灵活之处。
        //避免了if...else判断。我们可以有这样的意识,一旦你的逻辑里面充斥了大量
        //switch、if的时候来看看自己注册的场景是否可以继续细分
        DomainRegistry.getInstance().register(MetaEvent.class, this);
    }

    public String getAction() {
        return MetaActionEnums.CONFIRM_UPLOADED.name();
    }

    //适用于前后依赖的事件,通过优先级指定执行顺序
    public Integer getPriority() {
        return PriorityEnums.FIRST.getValue();
    }
}

事件总线逻辑

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;
import java.util.List;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DefaultDomainEventBus {

    public static void publish(DomainEvent event, String action,
                               EventCallback callback) {

        List<DomainEventHandler> handlers = DomainRegistry.getInstance().
            find(event.getClass().getName());
        handlers.stream().forEach(handler -> {
            if (action != null && action.equals(handler.getAction())) {
                Exception e = null;
                boolean result = true;
                try {
                    handler.handle(event);
                } catch (Exception ex) {
                    e = ex;
                    result = false;
                    //自定义异常处理
                    。。。
                } finally {
                    //write into event store
                    saveEvent(event);
                }

                //根据实际业务处理回调场景,DefaultEventCallback可以返回
                if (callback != null) {
                    callback.callback(event, action, result, e);       
                }
            }
        });
    }
}

五、自治服务和系统

DDD中强调限界上下文的自治特性,事实上,从更小的粒度来看,对象仍然需要具备自治的这四个特性,即:最小完备、自我履行、稳定空间、独立进化。其中自我履行是重点,因为不强依赖外部所以稳定、因为稳定才可能独立进化。这就是六边形架构在DDD中较为普遍的原因。

( 图片来源于网络)

六、结语

本文所讲述的事件、Saga、CQRS的方案均可以单独使用,可以应用到你的某个method、或者你的整个package。项目中我们并不一定要实践一整套CQRS,只要其中的某些思想解决了我们项目中的某个问题就足够了。

也许你现在已经磨刀霍霍,准备在项目中实践一下这些技巧。不过我们要明白“每一个硬币都有两面性”,我们不仅看到高扩展、解耦的、易编排的优点以外,仍然要明白其所带来的问题。利弊分析以后再去决定如何实现才是正确的应对之道。

  • 这类编程模式有一定的学习曲线;
  • 基于消息传递的应用程序的复杂性;
  • 处理事件的演化有一定难度;
  • 删除数据存在一定难度;
  • 查询事件存储库非常有挑战性。

不过我们还是要认识到在其适合的场景中,六边形架构以及DDD战术将加速我们的领域建模过程,也迫使我们从严格的通用语言角度来解释一个领域,而不是一个个需求。任何更强调核心域而不是技术实现的方式都可以增加业务价值,并使我们获得更大的竞争优势。

附:参考文献

  1. Pattern: Saga
  2. 分布式事务:Saga模式
  3. 书籍:《微服务架构设计模式》

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系。

查看原文

吴彦欣 赞了文章 · 2020-04-14

领域驱动设计(DDD)实践之路(二):事件驱动与CQRS

本文首发于 vivo互联网技术 微信公众号 
链接: https://mp.weixin.qq.com/s/Z3...
作者:wenbo zhang

【领域驱动设计实践之路】系列往期精彩文章:

领域驱动设计(DDD)实践之路(一)》 主要讲述了战略层面的DDD原则。

这是“领域驱动设计实践之路”系列的第二篇文章,分析了如何应用事件来分离软件核心复杂度。探究CQRS为什么广泛应用于DDD项目中,以及如何落地实现CQRS框架。当然我们也要警惕一些失败的教训,利弊分析以后再去抉择正确的应对之道。

一、前言:从物流详情开始

大家对物流跟踪都不陌生,它详细记录了在什么时间发生了什么,并且数据作为重要凭证是不可变的。我理解其背后的价值有这么几个方面:业务方可以管控每个子过程、知道目前所处的环节;另一方面,当需要追溯时候仅仅通过每一步的记录就可以回放整个历史过程。

我在之前的文章中提出过“软件项目也是人类社会生产关系的范畴,只不过我们所创造的劳动成果看不见摸不着而已”。所以我们可以借鉴物流跟踪的思路来开发软件项目,把复杂过程拆解为一个个步骤、子过程、状态,这和我们事件划分是一致的,这就是事件驱动的典型案例。

二、领域事件

领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获我们所建模的领域中所发生过的事情。

领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。

比如在前述的跨境物流例子中,货品达到保税仓以后需要分派工作人员进行分拣分包,那么“货品已到达保税仓”便是一个领域事件。

首先,从业务逻辑来说该事件关系到整个流程的成功或者失败;同时又将触发后续子流程;而对于业务方来说,该事件也是一个标志性的里程碑,代表自己的货品就快配送到自己手中。

所以通常来说,一个领域事件具有以下几个特征:较高的业务价值,有助于形成完整的业务闭环,将导致进一步的业务操作。这里还要强调一点,领域事件具有明确的边界。

比如:如果你建模的是餐厅的结账系统,那么此时的“客户已到达”便不是你关心的重点,因为你不可能在客户到达时就立即向对方要钱,而“客户已下单”才是对结账系统有用的事件。

1、建模领域事件

在建模领域事件时,我们应该根据限界上下文中的通用语言来命名事件及属性。如果事件由聚合上的命令操作产生,那么我们通常根据该操作方法的名字来命名领域事件。

对于上面的例子“货品已到达保税仓”,我们将发布与之对应的领域事件

GoodsArrivedBondedWarehouseEvent(当然在明确的界限上下文中也可以去掉聚合的名字,直接建模为ArrivedBondedWarehouseEvent,这都是命名方面的习惯)。

事件的名字表明了聚合上的命令方法在执行成功之后所发生的事情,换句话说待定项以及不确定的状态是不能作为领域事件的。

一个行之有效的方法是画出当前业务的状态流转图,包含前置操作以及引起的状态变更,这里表达的是已经变更完成的状态所以我们不用过去时态表示,比如删除或者取消,即代表已经删除或者已经取消。

然后对于其中的节点进行事件建模。如下图是文件云端存储的业务,我们分别对预上传、上传完成确认、删除等环节建模“过去时”事件,PreUploadedEvent、ConfirmUploadedEvent、RemovedEvent。

2、领域事件代码解读

package domain.event;

import java.util.Date;
import java.util.UUID;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */
public class DomainEvent {

    /**
     * 领域事件还包含了唯一ID,
     * 但是该ID并不是实体(Entity)层面的ID概念,
     * 而是主要用于事件追溯和日志。
     * 如果是数据库存储,该字段通常为唯一索引。
     */
    private final String id;

    /**
     * 创建时间用于追溯,另一方面不管使用了
     * 哪种事件存储都有可能遇到事件延迟,
     * 我们通过创建时间能够确保其发生顺序。
     */
    private final Date occurredOn;

    public DomainEvent() {
        this.id = String.valueOf(UUID.randomUUID());
        this.occurredOn = new Date();
    }
}

在创建领域事件时,需要注意2点:

  • 领域事件本身应该是不变的(Immutable);
  • 领域事件应该携带与事件发生时相关的上下文数据信息,但是并不是整个聚合根的状态数据。例如,在创建订单时可以携带订单的基本信息,而对于用户更新订单收货地址事件AddressUpdatedEvent事件,只需要包含订单、用户以及新的地址等信息即可。
public class AddressUpdatedEvent extends DomainEvent {
    //通过userId+orderId来校验订单的合法性;
    private String userId; 
    private String orderId;
    //新的地址
    private Address address;
    //略去具体业务逻辑
}

3、领域事件的存储

事件的不可变性与可追溯性都决定了其必须要持久化的原则,我们来看看常见的几种方案。

3.1单独的EventStore

有的业务场景中会创建一个单独的事件存储中心,可能是Mysql、Redis、Mongo、甚至文件存储等。这里以Mysql举例,business_code、event_code用来区分不同业务的不同事件,具体的命名规则可以根据实际需要。

这里需要注意该数据源与业务数据源不一致的场景,我们要确保当业务数据更新以后事件能够准确无误的记录下来,实践中尽量避免使用分布式事务,或者尽量避免其跨库的场景,否则你就得想想如何补偿了。千万要避免,用户更新了收货地址,但是AddressUpdatedEvent事件保存失败。

总的原则就是对分布式事务Say No,无论如何,我相信方法总比问题多,在实践中我们总可以想到解决方案,区别在于该方案是否简洁、是否做到了解耦。

# 考虑是否需要分表,事件存储建议逻辑简单
CREATE TABLE `event_store` (
  `event_id` int(11) NOT NULL auto increment,
  `event_code` varchar(32) NOT NULL,
  `event_name` varchar(64) NOT NULL,
  `event_body` varchar(4096) NOT NULL,
  `occurred_on` datetime NOT NULL,
  `business_code` varchar(128) NOT NULL,
  UNIQUE KEY (`event id`)
) ENGINE=InnoDB COMMENT '事件存储表';

3.2 与业务数据一起存储

在分布式架构中,每个模块都做的相对比较小,准确的说是“自治”。如果当前业务数据量较小,可以将事件与业务数据一起存储,用相关标识区分是真实的业务数据还是事件记录;或者在当前业务数据库中建立该业务自己的事件存储,但是要考虑到事件存储的量级必然大于真实的业务数据,考虑是否需要分表。

这种方案的优势:数据自治;避免分布式事务;不需要额外的事件存储中心。当然其劣势就是不能复用。

4、领域事件如何发布

4.1 由领域聚合发送领域事件

/*
* 一个关于比赛的充血模型例子
* 贫血模型会构造一个MatchService,我们这里通过模型来触发相应的事件
* 本例中略去了具体的业务细节
*/
public class Match {
    public void start() {
        //构造Event....
        MatchEvent matchStartedEvent = new MatchStartedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchStartedEvent);
    }

    public void finish() {
        //构造Event....
        MatchEvent matchFinishedEvent = new MatchFinishedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchFinishedEvent);
    }

    //略去Match对象基本属性
}

4.2 事件总线VS消息中间件

微服务内的领域事件可以通过事件总线或利用应用服务实现不同聚合之间的业务协同。即微服务内发生领域事件时,由于大部分事件的集成发生在同一个线程内,不一定需要引入消息中间件。但一个事件如果同时更新多个聚合数据,按照 DDD“一个事务只更新一个聚合根”的原则,可以考虑引入消息中间件,通过异步化的方式,对微服务内不同的聚合根采用不同的事务

三、Saga分布式事务

1、Saga概要

我们看看如何使用 Saga 模式维护数据一致性?

Saga 是一种在微服务架构中维护数据一致性的机制,它可以避免分布式事务所带来的问题。

一个 Saga 表示需要更新的多个服务中的一个,即Saga由一连串的本地事务组成。每一个本地事务负责更新它所在服务的私有数据库,这些操作仍旧依赖于我们所熟悉的ACID事务框架和函数库。

模式:Saga

通过使用异步消息来协调一系列本地事务,从而维护多个服务之间的数据一致性。

请参阅(强烈建议):https://microservices.io/patterns/data/saga.html

Saga与TCC相比少了一步Try的操作,TCC无论最终事务成功失败都需要与事务参与方交互两次。而Saga在事务成功的情况下只需要与事务参与方交互一次, 如果事务失败,需要额外进行补偿回滚。

  • 每个Saga由一系列sub-transaction Ti 组成;
  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果;

可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

  • success:T1, T2, T3, ..., Tn ;
  • failure:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n;

所以我们可以看到Saga的撤销十分关键,可以说使用Saga的难点就在于如何设计你的回滚策略。

2、Saga实现

通过上面的例子我们对Saga有了初步的体感,现在来深入探讨下如何实现。当通过系统命令启动Saga时,协调逻辑必须选择并通知第一个Saga参与方执行本地事务。一旦该事务完成,Saga协调选择并调用下一个Saga参与方。

这个过程一直持续到Saga执行完所有步骤。如果任何本地事务失败,则 Saga必须以相反的顺序执行补偿事务。以下几种不同的方法可用来构建Saga的协调逻辑。

2.1 协同式(choreography)

把 Saga 的决策和执行顺序逻辑分布在 Saga的每一个参与方中,它们通过交换事件的方式来进行沟通。

( 引用于《微服务架构设计模式》相关章节)

  1. Order服务创建一个Order并发布OrderCreated事件。
  2. Consumer服务消费OrderCreated事件,验证消费者是否可以下订单,并发布ConsumerVerified事件。
  3. Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下创建故障单,并发布TicketCreated事件。
  4. Accounting服务消费OrderCreated事件并创建一个处于PENDING状态的Credit CardAuthorization。
  5. Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡授权失败事件。
  6. Kitchen服务使用信用卡授权失败事件并将故障单的状态更改为REJECTED。
  7. 订单服务消费信用卡授权失败事件,并将订单状态更改为已拒绝。

2.2 编排式(orchestration)

把Saga的决策和执行顺序逻辑集中在一个Saga编排器类中。Saga 编排器发出命令式消息给各个 Saga 参与方,指示这些参与方服务完成具体操作(本地事务)。类似于一个状态机,当参与方服务完成操作以后会给编排器发送一个状态指令,以决定下一步做什么。

( 引用于《微服务架构设计模式》相关章节)

我们来分析一下执行流程

  1. Order Service首先创建一个Order和一个创建订单控制器。之后,路径的流程如下:
  2. Saga orchestrator向Consumer Service发送Verify Consumer命令。
  3. Consumer Service回复Consumer Verified消息。
  4. Saga orchestrator向Kitchen Service发送Create Ticket命令。
  5. Kitchen Service回复Ticket Created消息。
  6. Saga协调器向Accounting Service发送授权卡消息。
  7. Accounting服务部门使用卡片授权消息回复。
  8. Saga orchestrator向Kitchen Service发送Approve Ticket命令。
  9. Saga orchestrator向订单服务发送批准订单命令。

2.3 补偿策略

之前的描述中我们说过Saga最重要的是如何处理异常,状态机还定义了许多异常状态。如上面的6就会发生失败,触发AuthorizeCardFailure,此时我们就要结束订单并把之前提交的事务进行回滚。这里面要区分哪些是校验性事务、哪些是需要补偿的事务。

 一个Saga由三种不同类型的事务组成:可补偿性事务(可以回滚,因此有一个补偿事务);关键性事务(这是 Saga的成败关键点,比如4账户代扣);以及可重复性事务,它不需要回滚并保证能够完成(比如6更新状态)。

在Create Order Saga 中,createOrder()、createTicket()步骤是可补偿性事务且具有撤销其更新的补偿事务。

verifyConsumerDetails()事务是只读的,因此不需要补偿事务。authorizeCreditCard()事务是这个 Saga的关键性事务。如果消费者的信用卡可以授权,那么这个Saga保证完成。approveTicket()和approveRestaurantOrder()步骤是在关键性事务之后的可重复性事务。

认真拆解每个步骤、然后评估其补偿策略尤为重要,正如你看到的,每种类型的事务在对策中扮演着不同的角色。

四、CQRS

前面讲述了事件的概念,又分析了Saga如何解决复杂事务,现在我们来看看CQRS为什么在DDD中广泛被采用。除了读写分离的特征以外,我们用事件驱动的方式来实践Command逻辑能有效降低业务的复杂度。

当你明白如何建模事件、如何规避复杂事务,明白什么时候用消息中间件、什么时候采用事件总线,才能理解为什么是CQRS、怎么正确应用。

( 图片来源于网络)

下面是我们项目中的设计,这里为什么会出现Read/Write Service,是为了封装调用,service内部是基于聚合发送事件。因为我发现在实际项目中,很多人都会第一时间问我要XXXService而不是XXX模型,所以在DDD没有完全普及的项目中建议大家采取这种居中策略。这也符合咱们的解耦,对方依赖我的抽象能力,然而我内部是基于DDD还是传统的流程代码对其是无关透明的。

我们先来看看事件以及处理器的时序关系。

这里还是以文件云端存储业务为例,下面是一些处理器的核心代码。注释行是对代码功能、用法以及扩展方面的解读,请认真阅读。

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @Description: 事件注册逻辑
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DomainRegistry {

    private Map<String, List<DomainEventHandler>> handlerMap =
        new HashMap<String, List<DomainEventHandler>>();

    private static DomainRegistry instance;

    private DomainRegistry() {
    }

    public static DomainRegistry getInstance() {
        if (instance == null) {
            instance = new DomainRegistry();
        }
        return instance;
    }

    public Map<String, List<DomainEventHandler>> getHandlerMap() {
        return handlerMap;
    }

    public List<DomainEventHandler> find(String name) {
        if (name == null) {
            return null;
        }
        return handlerMap.get(name);
    }

    //事件注册与维护,register分多少个场景根据业务拆分,
    //这里是业务流的核心。如果多个事件需要维护前后依赖关系,
    //可以维护一个priority逻辑
    public void register(Class<? extends DomainEvent> domainEvent,
                         DomainEventHandler handler) {
        if (domainEvent == null) {
            return;
        }
        if (handlerMap.get(domainEvent.getName()) == null) {
            handlerMap.put(domainEvent.getName(), new ArrayList<DomainEventHandler>());
        }
        handlerMap.get(domainEvent.getName()).add(handler);
        //按照优先级进行事件处理器排序
        。。。
    }
}

文件上传完毕事件的例子。

package domain.handler.event;

import domain.DomainRegistry;
import domain.StateDispatcher;
import domain.entity.meta.MetaActionEnums;
import domain.event.DomainEvent;
import domain.event.MetaEvent;
import domain.repository.meta.MetaRepository;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

/**
 * @Description:一个事件操作的处理器
 * 我们混合使用了Saga的两种模式,外层事件交互;
 * 对于单个复杂的事件内部采取状态流转实现。
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

@Component
public class MetaConfirmUploadedHandler implements DomainEventHandler {

    @Resource
    private MetaRepository metaRepository;

    public void handle(DomainEvent event) {
        //1.我们在当前的上下文中定义个ThreadLocal变量
        //用于存放事件影响的聚合根信息(线程共享)

        //2.当然如果有需要额外的信息,可以基于event所
        //携带的信息构造Specification从repository获取
        // 代码示例
        // metaRepository.queryBySpecification(SpecificationFactory.build(event));

        DomainEvent domainEvent = metaRepository.load();

        //此处是我们的逻辑
        。。。。

        //对于单个操作比较复杂的,可以使用状态流转进一步拆分
        domainEvent.setStatus(nextState);
        //在事件触发之后,仍需要一个状态跟踪器来解决大事务问题
        //Saga编排式
        StateDispatcher.dispatch();
    }

    @PostConstruct
    public void autoRegister() {
        //此处可以更加细分,注册在哪一类场景中,这也是事件驱动的强大、灵活之处。
        //避免了if...else判断。我们可以有这样的意识,一旦你的逻辑里面充斥了大量
        //switch、if的时候来看看自己注册的场景是否可以继续细分
        DomainRegistry.getInstance().register(MetaEvent.class, this);
    }

    public String getAction() {
        return MetaActionEnums.CONFIRM_UPLOADED.name();
    }

    //适用于前后依赖的事件,通过优先级指定执行顺序
    public Integer getPriority() {
        return PriorityEnums.FIRST.getValue();
    }
}

事件总线逻辑

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;
import java.util.List;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DefaultDomainEventBus {

    public static void publish(DomainEvent event, String action,
                               EventCallback callback) {

        List<DomainEventHandler> handlers = DomainRegistry.getInstance().
            find(event.getClass().getName());
        handlers.stream().forEach(handler -> {
            if (action != null && action.equals(handler.getAction())) {
                Exception e = null;
                boolean result = true;
                try {
                    handler.handle(event);
                } catch (Exception ex) {
                    e = ex;
                    result = false;
                    //自定义异常处理
                    。。。
                } finally {
                    //write into event store
                    saveEvent(event);
                }

                //根据实际业务处理回调场景,DefaultEventCallback可以返回
                if (callback != null) {
                    callback.callback(event, action, result, e);       
                }
            }
        });
    }
}

五、自治服务和系统

DDD中强调限界上下文的自治特性,事实上,从更小的粒度来看,对象仍然需要具备自治的这四个特性,即:最小完备、自我履行、稳定空间、独立进化。其中自我履行是重点,因为不强依赖外部所以稳定、因为稳定才可能独立进化。这就是六边形架构在DDD中较为普遍的原因。

( 图片来源于网络)

六、结语

本文所讲述的事件、Saga、CQRS的方案均可以单独使用,可以应用到你的某个method、或者你的整个package。项目中我们并不一定要实践一整套CQRS,只要其中的某些思想解决了我们项目中的某个问题就足够了。

也许你现在已经磨刀霍霍,准备在项目中实践一下这些技巧。不过我们要明白“每一个硬币都有两面性”,我们不仅看到高扩展、解耦的、易编排的优点以外,仍然要明白其所带来的问题。利弊分析以后再去决定如何实现才是正确的应对之道。

  • 这类编程模式有一定的学习曲线;
  • 基于消息传递的应用程序的复杂性;
  • 处理事件的演化有一定难度;
  • 删除数据存在一定难度;
  • 查询事件存储库非常有挑战性。

不过我们还是要认识到在其适合的场景中,六边形架构以及DDD战术将加速我们的领域建模过程,也迫使我们从严格的通用语言角度来解释一个领域,而不是一个个需求。任何更强调核心域而不是技术实现的方式都可以增加业务价值,并使我们获得更大的竞争优势。

附:参考文献

  1. Pattern: Saga
  2. 分布式事务:Saga模式
  3. 书籍:《微服务架构设计模式》

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系。

查看原文

赞 9 收藏 5 评论 0

吴彦欣 评论了文章 · 2019-03-04

GitLab-CI 从安装到差点放弃

故事是这样的..
我们源码从github迁移到自主搭建的gitlab服务器管理,以前用github的时候是使用jenkins进行持续集成的,本来应用上jenkins我只要配一下webhook就可以了,可我就是任性。
我心想,既然已经迁移到gitlab了,为何不用用gitlab-ci呢,更何况gitlab宣称集成了gitlab-ci,应该很快就能应用上。
我正是这样把自己推进坑的。

名词解释

进坑前先理清一些名词,以及他们之间的关系。

1. Gitlab

GitLab是一个利用Ruby on Rails开发的开源应用程序,实现一个自托管的Git项目仓库,可通过Web界面进行访问公开的或者私人项目。
它拥有与GitHub类似的功能,能够浏览源代码,管理缺陷和注释。可以管理团队对仓库的访问,它非常易于浏览提交过的版本并提供一个文件历史库。团队成员可以利用内置的简单聊天程序(Wall)进行交流。它还提供一个代码片段收集功能可以轻松实现代码复用,便于日后有需要的时候进行查找。

2. Gitlab-CI

Gitlab-CI是GitLab Continuous Integration(Gitlab持续集成)的简称。
从Gitlab的8.0版本开始,gitlab就全面集成了Gitlab-CI,并且对所有项目默认开启。
只要在项目仓库的根目录添加.gitlab-ci.yml文件,并且配置了Runner(运行器),那么每一次合并请求(MR)或者push都会触发CI pipeline

3. Gitlab-runner

Gitlab-runner.gitlab-ci.yml脚本的运行器,Gitlab-runner是基于Gitlab-CI的API进行构建的相互隔离的机器(或虚拟机)。GitLab Runner 不需要和Gitlab安装在同一台机器上,但是考虑到GitLab Runner的资源消耗问题和安全问题,也不建议这两者安装在同一台机器上。

Gitlab Runner分为两种,Shared runners和Specific runners。
Specific runners只能被指定的项目使用,Shared runners则可以运行所有开启 Allow shared runners选项的项目。

4. Pipelines

Pipelines是定义于.gitlab-ci.yml中的不同阶段的不同任务。
我把Pipelines理解为流水线,流水线包含有多个阶段(stages),每个阶段包含有一个或多个工序(jobs),比如先购料、组装、测试、包装再上线销售,每一次push或者MR都要经过流水线之后才可以合格出厂。而.gitlab-ci.yml正是定义了这条流水线有哪些阶段,每个阶段要做什么事。

5. Badges

徽章,当Pipelines执行完成,会生成徽章,你可以将这些徽章加入到你的README.md文件或者你的网站。

徽章的链接形如:
http://example.gitlab.com/namespace/project/badges/branch/build.svg
我们用gitlab项目的徽章作为例子:

安装配置

这里跳过Gitlab的安装,请自行谷歌。但是对于安装gitlab有一点提醒,就是建议使用官方推荐的集成安装包的方式安装,通过源码安装会有很多坑踩不完。

安装gitlab-ci-multi-runner

  1. 如果想要使用docker runner,则需要安装docker。(可选)
    curl -sSL https://get.docker.com/ | sh
    因为docker需要linux内核在3.10或以上,安装前可以通过uname -r查看Linux内核版本。
  2. 添加Gitlab的官方源:

    # For Debian/Ubuntu
    curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.deb.sh | sudo bash
    
    # For CentOS
    curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-ci-multi-runner/script.rpm.sh | sudo bash
    
  3. 安装

    # For Debian/Ubuntu
    sudo apt-get install gitlab-ci-multi-runner
    
    # For CentOS
    sudo yum install gitlab-ci-multi-runner
  4. 注册Runner
    Runner需要注册到Gitlab才可以被项目所使用,一个gitlab-ci-multi-runner服务可以注册多个Runner。

    $ sudo gitlab-ci-multi-runner register
    
    Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com )
    https://mygitlab.com/ci
    Please enter the gitlab-ci token for this runner
    xxx-xxx-xxx
    Please enter the gitlab-ci description for this runner
    my-runner
    INFO[0034] fcf5c619 Registering runner... succeeded
    Please enter the executor: shell, docker, docker-ssh, ssh?
    docker
    Please enter the Docker image (eg. ruby:2.1):
    node:4.5.0
    INFO[0037] Runner registered successfully. Feel free to start it, but if it's
    running already the config should be automatically reloaded!
  5. 更新Runner
    如果需要更新Runner,只需要执行以下脚本:
# For Debian/Ubuntu
sudo apt-get update
sudo apt-get install gitlab-ci-multi-runner

# For CentOS
sudo yum update
sudo yum install gitlab-ci-multi-runner
  1. Runner高级配置
    通过gitlab-ci-multi-runner register注册的Runner配置会存储在/etc/gitlab-runner/config.toml中,如果需要修改可直接编辑该文件。详见这里
concurrent = 4
check_interval = 0

[[runners]]
name = "test"
url = "http://your-domain.com/ci"
token = "your-token"
executor = "docker"
[runners.docker]
  tls_verify = false
  image = "node:4.5.0"
  privileged = false
  disable_cache = false
  volumes = ["/cache"]
[runners.cache]
[runners.kubernetes]
  host = ""
  cert_file = ""
  key_file = ""
  ca_file = ""
  image = ""
  namespace = ""
  privileged = false
  cpus = ""
  memory = ""
  service_cpus = ""
  service_memory = ""

到这里我们的Runner就安装配置好了,接下来是对项目根目录中.gitlab-ci.yml进行配置。

配置构建任务

  1. 在项目根目录添加.gitlab-ci.yml文件
    关于该文件的各项配置请见
  2. 示例:
# 这里使用了我自己的docker image,配置了自己需要的环境
image: wuyanxin/node

variables:
MYSQL_DATABASE: wan_ark-unit
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"

# 关于service请见: http://docs.gitlab.com/ce/ci/docker/using_docker_images.html#what-is-service
services:
- mysql:5.6
- redis:3.2.4

stages:
- test
- eslint
- deploy

before_script:
  - echo 'REDIS_HOST=redis' >> .env
  - echo 'DB_HOST=mysql' >> .env
  - yarn install

test_service:
stage: test
script:
  - npm run build
  - npm test

eslint_src: 
stage: eslint
script:
  - npm run lint
allow_failure: true

deploy:
stage: deploy
script:
  - echo 'deployd!'
only: 
  - master

这里使用了nodejs项目作为例子,其他语言类似语法。

  1. 执行结果
    pipeline 截图
    详细截图

最后

这是我最近对于Gitlab CI的实验记录,对于Gitlab CI的使用体验我给82分。虽然在实验过程中踩了很多坑,真的踩到差点放弃了,所以记录一下我的实验过程,希望对他人有帮助。
关于在这个过程中踩到的坑以及构建速度优化请关注我下期文章。

参考

  1. https://zh.wikipedia.org/wiki...
  2. https://docs.gitlab.com/ce/ci...
查看原文

吴彦欣 收藏了文章 · 2018-11-09

支付系统常用设计原则

参考:http://www.jianshu.com/p/65b1...

一 表结构设计

用户资产表:CustomerBalanceAsset

储存用户余额资产。比如返现活动等,例如评价返现30元,一般我们都是返到用户余额;另外一些项目有充值需求,也可以把第三方支付结构或者银行机构内的资金充值到余额中。

资产变动记录表: CustomerBalanceAssetLog

储存用户余额资产变动记录。因为资产肯定会发生变动(增减),必须需要log表例如customer_balance_log(可以业务代码实现写入也可以通过数据库的event实现,建议采用前者)记录所有的资金变动详情,方便对账补偿差错。

红包表:CustomerCouponAsset

储存用户红包资产,用户参加红包活动,领取红包资产,会记录到这张表中。

红包日志表: CustomerCouponAssetLog

储存用户红包资产变动记录,消费,过期等
...... 另外一些积分,抵扣券可以沿用类似逻辑

支付信息表:Payment

储存用户支付行为信息。关键字段有总支付金额,支付创建人,支付状态等。多个订单可能合并成一个支付,或者一个订单分拆成多个支付,所以需要和Order表多对多映射

流水表:Transaction

储存用户资金单据(流水),关键字段为支付渠道,支付金额,支付时间,支付状态等。其与Payment为多对一关系。但是不同的Transaction有不同的字段,例如微信支付有商户订单号,预支付单号等,这些字段是余额资金单据不需要的,如果设计一个大Transaction表的话,不利于扩展,也会造成不少记录会出现空字段。

因此可以根据不同的资金单据设置不同的数据模型,比如
资产流水表:BalanceTransactionInfo
红包流水表:CouponTransactionInfo
微信流水表:WeixinTransactionInfo字表
支付宝流水表:AlipayTransactionInfo表
......
以共享主键的方式与Transaction表建立一对一关系。
共享主键
为了提高数据库性能。比如我们有一张user表,有id、用户名,姓名、年龄、地址等等信息,但常用的可能只有用户名、姓名。那么如果我们将所有的字段放在一起会带来不必要的效率损失,比如查询出来大量无用字段,此时就可以拆成两张表,常用字段放到一张表,不常用的放到另外一张,并且是采用同一个主键。

二 可靠性

存在调用支付接口支付成功,回来之后你要更新表状态啥的,万一更新失败了呢?抛异常了,你是给用户反馈支付成功了还是失败了?调用支付接口成功后当然是已经支付成功喽,那么这个时候就应该直接返回给用户支付成功,而后可以使用消息队列将付款后的一系列操作扔到消息队列里去,让它自己去玩。

同样的道理适用于与供应商交互,当有一些关键操作时,都可以使用异步队列来确保执行完成。

查看原文

吴彦欣 赞了文章 · 2018-08-26

iframe,我们来谈一谈

某大咖说: "iframe是能耗最高的一个元素,请尽量减少使用"
某大牛说: "iframe安全性太差,请尽量减少使用"
...
wtf, 你们知不知道你们这样浇灭了多少孩纸学习iframe的热情和决心。 虽然,你们这样说的我竟无法反驳,但是iframe强大功能是不容忽视的。 可以看看各大邮箱网站是否还在使用iframe,查查知乎iframe. iframe不死,我心不灭。现在给大家安利一下iframe.

iframe基本内涵

通常我们使用iframe直接直接在页面嵌套iframe标签指定src就可以了。

<iframe data-original="demo_iframe_sandbox.htm"></iframe> 

但是,有追求的我们,并不是想要这么low的iframe. 我们来看看在iframe中还可以设置些什么属性

iframe常用属性:
1.frameborder:是否显示边框,1(yes),0(no)

2.height:框架作为一个普通元素的高度,建议在使用css设置。

3.width:框架作为一个普通元素的宽度,建议使用css设置。

4.name:框架的名称,window.frames[name]时专用的属性。

5.scrolling:框架的是否滚动。yes,no,auto。

6.src:内框架的地址,可以使页面地址,也可以是图片的地址。
7.srcdoc , 用来替代原来HTML body里面的内容。但是IE不支持, 不过也没什么卵用
8.sandbox: 对iframe进行一些列限制,IE10+支持

上面一些tag,会在下文进行穿插说明,单个不好说。
我们通常使用iframe最基本的特性,就是能自由操作iframe和父框架的内容(DOM). 但前提条件是同域. 如果跨域顶多只能实现页面跳转window.location.href.
那什么是同域/ 什么是跨域呢?
就是判断你的url首部是否一样,下面会有讲解,这里只是提及。
同域不同域的问题:

A:<iframe id="mainIframe" name="mainIframe" data-original="/main.html" frameborder="0" scrolling="auto" ></iframe>

B:<iframe id="mainIframe" name="mainIframe" data-original="http://www.baidu.com" frameborder="0" scrolling="auto" ></iframe>

使用A时,因为同域,父页面可以对子页面进行改写,反之亦然。
使用B时,不同域,父页面没有权限改动子页面,但可以实现页面的跳转
这里,我们先从简单的开始,当主页面和iframe同域时,我们可以 些什么。

获取iframe里的内容

主要的两个API就是contentWindow,和contentDocument
iframe.contentWindow, 获取iframe的window对象
iframe.contentDocument, 获取iframe的document对象
这两个API只是DOM节点提供的方式(即getELement系列对象)

 var iframe = document.getElementById("iframe1");
 var iwindow = iframe.contentWindow;
 var idoc = iwindow.document;
        console.log("window",iwindow);//获取iframe的window对象
        console.log("document",idoc);  //获取iframe的document
        console.log("html",idoc.documentElement);//获取iframe的html
        console.log("head",idoc.head);  //获取head
        console.log("body",idoc.body);  //获取body

实际情况如:

另外更简单的方式是,结合Name属性,通过window提供的frames获取.

<iframe src ="/index.html" id="ifr1" name="ifr1" scrolling="yes">
  <p>Your browser does not support iframes.</p>
</iframe>
<script type="text/javascript">
    console.log(window.frames['ifr1'].window);
console.dir(document.getElementById("ifr1").contentWindow);
</script>

其实window.frames['ifr1']返回的就是window对象,即

window.frames['ifr1']===window

这里就看你想用哪一种方式获取window对象,两者都行,不过本人更倾向于第二种使用frames[xxx].因为,字母少啊喂~ 然后,你就可以操控iframe里面的DOM内容。

在iframe中获取父级内容

同理,在同域下,父页面可以获取子iframe的内容,那么子iframe同样也能操作父页面内容。在iframe中,可以通过在window上挂载的几个API进行获取.

window.parent 获取上一级的window对象,如果还是iframe则是该iframe的window对象
window.top 获取最顶级容器的window对象,即,就是你打开页面的文档
window.self 返回自身window的引用。可以理解 window===window.self(脑残)

如图: 来个栗子~

ok, 获取了之后,我们就可以进行相关操作了。 在同域的iframe中,我们可以巧妙的使用iframe的黑科技来实现一些trick.

iframe的轮询

话说在很久很久以前,我们实现异步发送请求是使用iframe实现的~!
怎么可能!!!
真的史料为证(自行google), 那时候为了不跳转页面,提交表单时是使用iframe提交的。现在,前端发展尼玛真快,websocket,SSE,ajax等,逆天skill的出现,颠覆了iframe, 现在基本上只能活在IE8,9的浏览器内了。 但是,宝宝以为这样就可以不用了解iframe了,而现实就是这么残酷,我们目前还需要兼容IE8+。所以,iframe 实现长轮询和长连接的trick 我们还是需要涉猎滴。

iframe长轮询

如果写过ajax的童鞋,应该知道,长轮询就是在ajax的readyState = 4的时,再次执行原函数即可。 这里使用iframe也是一样,异步创建iframe,然后reload, 和后台协商好, 看后台哥哥们将返回的信息放在,然后获取里面信息即可. 这里是直接放在body里.

var iframeCon = docuemnt.querySelector('#container'),
        text; //传递的信息
    var iframe = document.createElement('iframe'),
        iframe.id = "frame",
        iframe.style = "display:none;",
        iframe.name="polling",
        iframe.data-original="target.html";
    iframeCon.appendChild(iframe);
    iframe.onload= function(){
        var iloc = iframe.contentWindow.location,
            idoc  = iframe.contentDocument;
        setTimeout(function(){
            text = idoc.getElementsByTagName('body')[0].textContent;
            console.log(text);
            iloc.reload(); //刷新页面,再次获取信息,并且会触发onload函数
        },2000);
    }

这样就可以实现ajax的长轮询的效果。 当然,这里只是使用reload进行获取,你也可以添加iframe和删除iframe的方式,进行发送信息,这些都是根据具体场景应用的。另外在iframe中还可以实现异步加载js文件,不过,iframe和主页是共享连接池的,所以还是很蛋疼的,现在基本上都被XHR和hard calllback取缔了,这里也不过多介绍了。

自适应iframe之蜜汁广告

网页为了赚钱,引入广告是很正常的事了。通常的做法就是使用iframe,引入广告地址就可以了,然后根据广告内容设置相应的显示框。但是,为什么是使用iframe来进行设置,而不是在某个div下嵌套就行了呢?
要知道,广告是与原文无关的,这样硬编码进去,会造成网页布局的紊乱,而且,这样势必需要引入额外的css和js文件,极大的降低了网页的安全性。 这些所有的弊端,都可以使用iframe进行解决。
我们通常可以将iframe理解为一个沙盒,里面的内容能够被top window 完全控制,而且,主页的css样式是不会入侵iframe里面的样式,这些都给iframe的广告命运埋下伏笔。可以看一下各大站点是否都在某处放了些广告,以维持生计比如:W3School
但,默认情况下,iframe是不适合做展示信息的,所以我们需要对其进行decorate.

自适应iframe

默认情况下,iframe会自带滚动条,不会全屏.如果你想自适应iframe的话:
第一步:去掉滚动条

<iframe data-original="./iframe1.html" id="iframe1" scrolling="no"></iframe>

第二步,设置iframe的高为body的高

var iwindow = iframe.contentWindow;
var idoc = iwindow.document;
iframe.height = idoc.body.offsetHeight;

另外,还可以添加其它的装饰属性:

属性效果
allowtransparencytrue or false
是否允许iframe设置为透明,默认为false
allowfullscreentrue or false
是否允许iframe全屏,默认为false

看个例子:

<iframe id="google_ads_frame2" name="google_ads_frame2" width="160" height="600" frameborder="0" data-original="target.html" marginwidth="0" marginheight="0" vspace="0" hspace="0" allowtransparency="true" scrolling="no" allowfullscreen="true"></iframe>

你可以直接写在内联里面,也可以在css里面定义,不过对于广告iframe来说,样式写在属性中,是best pratice.

iframe安全性探索

iframe出现安全性有两个方面,一个是你的网页被别人iframe,一个是你iframe别人的网页。 当你的网页被别人iframe时,比较蛋疼的是被钓鱼网站利用,然后victim+s留言逼逼你。真.简直了。 所以为了防止页面被一些不法分子利用,我们需要做好安全性措施。

防嵌套网页

比如,最出名的clickhacking就是使用iframe来 拦截click事件。因为iframe享有着click的最优先权,当有人在伪造的主页中进行点击的话,如果点在iframe上,则会默认是在操作iframe的页面。 所以,钓鱼网站就是使用这个技术,通过诱导用户进行点击,比如,设计一个"妹妹寂寞了"等之类的网页,诱导用户点击,但实际结果,你看到的不是"妹妹",而是被恶意微博吸粉。
所以,为了防止网站被钓鱼,可以使用window.top来防止你的网页被iframe.

//iframe2.html
if(window != window.top){
    window.top.location.href = correctURL;
}

这段代码的主要用途是限定你的网页不能嵌套在任意网页内。
如果你想引用同域的框架的话,可以判断域名。

if (top.location.host != window.location.host) {
  top.location.href = window.location.href;
}

当然,如果你网页不同域名的话,上述就会报错。
所以,这里可以使用try...catch...进行错误捕获。如果发生错误,则说明不同域,表示你的页面被盗用了。可能有些浏览器这样写是不会报错,所以需要降级处理。
这时候再进行跳转即可.

try{
  top.location.hostname;  //检测是否出错
  //如果没有出错,则降级处理
  if (top.location.hostname != window.location.hostname) { 
    top.location.href =window.location.href;
  }
}
catch(e){
  top.location.href = window.location.href;
}

这只是浏览器端,对iframe页面的权限做出相关的设置。
我们还可以在服务器上,对使用iframe的权限进行设置.

X-Frame-Options

X-Frame-Options是一个相应头,主要是描述服务器的网页资源的iframe权限。目前的支持度是IE8+(已经很好了啊喂)
有3个选项:

DENY:当前页面不能被嵌套iframe里,即便是在相同域名的页面中嵌套也不允许,也不允许网页中有嵌套iframe
SAMEORIGIN:iframe页面的地址只能为同源域名下的页面
ALLOW-FROM:可以在指定的origin url的iframe中加载

简单实例:

X-Frame-Options: DENY
拒绝任何iframe的嵌套请求

X-Frame-Options: SAMEORIGIN
只允许同源请求,例如网页为 foo.com/123.php,則 foo.com 底下的所有网页可以嵌入此网页,但是 foo.com 以外的网页不能嵌入


X-Frame-Options: ALLOW-FROM http://s3131212.com
只允许指定网页的iframe请求,不过兼容性较差Chrome不支持

X-Frame-Options其实就是将前端js对iframe的把控交给服务器来进行处理。

//js
if(window != window.top){
    window.top.location.href = window.location.href;
}
//等价于
X-Frame-Options: DENY

//js
if (top.location.hostname != window.location.hostname) { 
    top.location.href =window.location.href;
}
//等价于
X-Frame-Options: SAMEORIGIN

该属性是对页面的iframe进行一个主要限制,不过,涉及iframe的header可不止这一个,另外还有一个Content Security Policy, 他同样也可以对iframe进行限制,而且,他应该是以后网页安全防护的主流。

CSP之页面防护

和X-Frames-Options一样,都需要在服务器端设置好相关的Header. CSP 的作用, 真的是太大了,他能够极大的防止你的页面被XSS攻击,而且可以制定js,css,img等相关资源的origin,防止被恶意注入。不过他的兼容性,也是渣的一逼。目前支持Edge12+ 以及 IE10+。
而且目前市面上,流行的是3种CSP头,以及各种浏览器的兼容性

使用主要是在后端服务器上配置,在前端,我们可以观察Response Header 里是否有这样的一个Header:

Content-Security-Policy: default-src 'self'

这就表明,你的网页是启用CSP的。
通常我们可以在CSP后配置各种指定资源路径,有

default-src,
script-src,
style-src,
img-src,
connect-src,
font-src,
object-src,
media-src,
sandbox,
child-src,
...

如果你未指定的话,则是使用default-src规定的加载策略.
默认配置就是同域: default-src "self".
这里和iframe有一点瓜葛的就是 child-src 和 sandbox.
child-src就是用来指定iframe的有效加载路径。其实和X-Frame-Options中配置allow-origin是一个道理。不过,allow-origin 没有得到厂商们的支持。
而,sandbox其实就和iframe的sandbox属性(下文介绍),是一样一样的,他可以规定来源能够带有什么权限.
来个demo:

Content-Security-Policy: child-src 'self' http://example.com; sandbox allow-forms allow-same-origin

此时,iframe的src就只能加载同域和example.com页面。 最后再补充一点: 使用CSP 能够很好的防止XSS攻击,原理就是CSP会默认escape掉内联样式和脚本,以及eval执行。但是,你可以使用srcipt-src进行降低限制.

Content-Security-Policy: script-src 'unsafe-inline'

如果想更深入的了解CSP,可以参阅:CSP,中文CSP,H5rock之CSP
ok, 上面基本上就是防止自己页面被嵌套而做的一些安全防护工作。 当然,我们面临的安全问题还有一个,就是当iframe别人的页面时,我们需要对其进行安全设限,虽然,跨域时iframe的安全性会大很多,但是,互联网是没有安全的地方。在以前,我们会进行各种trick来防止自己的页面被污染,现在h5提供的一个新属性sandbox可以很好的解决这个问题。

sandbox

sandbox就是用来给指定iframe设置一个沙盒模型限制iframe的更多权限.
sandbox是h5的一个新属性,IE10+支持(md~).
启用方式就是使用sandbox属性:

<iframe sandbox data-original=”...”></iframe>

这样会对iframe页面进行一系列的限制:

1. script脚本不能执行
2. 不能发送ajax请求
3. 不能使用本地存储,即localStorage,cookie等
4. 不能创建新的弹窗和window
5. 不能发送表单
6. 不能加载额外插件比如flash等

看到这里,我也是醉了。 好好的一个iframe,你这样是不是有点过分了。 不过,你可以放宽一点权限。在sandbox里面进行一些简单设置

<iframe sandbox=”allow-same-origin” data-original=”...”></iframe>

常用的配置项有:

配置效果
allow-forms允许进行提交表单
allow-scripts运行执行脚本
allow-same-origin允许同域请求,比如ajax,storage
allow-top-navigation允许iframe能够主导window.top进行页面跳转
allow-popups允许iframe中弹出新窗口,比如,window.open,target="_blank"
allow-pointer-lock在iframe中可以锁定鼠标,主要和鼠标锁定有关

可以通过在sandbox里,添加允许进行的权限.

<iframe sandbox=”allow-forms allow-same-origin allow-scripts” data-original=”...”></iframe>

这样,就可以保证js脚本的执行,但是禁止iframe里的javascript执行top.location = self.location。
哎,其实,iframe的安全问题还是超级有的。比如location劫持,Refers检查等。 不过目前而言,知道怎么简单的做一些安全措施就over了,白帽子们会帮我们测试的。

resolve iframe跨域

iframe就是一个隔离沙盒,相当于我们在一个页面内可以操控很多个标签页一样。如果踩坑的童鞋应该知道,iframe的解决跨域也是很有套套的。
首先我们需要明确什么是跨域。
浏览器判断你跨没跨域,主要根据两个点。 一个是你网页的协议(protocol),一个就是你的host是否相同,即,就是url的首部:

window.location.protocol +window.location.host


具体的例子就是:

需要强调的是,url首部必须一样,比如:二级域名或者IP地址,都算是跨域.

//域名和域名对应ip, 跨域
http://www.a.com/a.js
http://70.32.92.74/b.js

//统一域名,不同二级域名。 跨域
http://www.a.com/a.js
http://a.com/b.js

对于第二种方式的跨域(主域相同而子域不同),可以使用iframe进行解决。
在两个不同子域下(某一方使用iframe嵌套在另一方),
即:
http: //www.foo.com/a.html和http: //script.foo.com/b.html
两个文件中分别加上document.domain = ‘foo.com’,指定相同的主域,然后,两个文档就可以进行交互。

//b.html是以iframe的形式嵌套在a.html中

//www.foo.com上的a.html

document.domain = 'foo.com';
var ifr = document.createElement('iframe');
ifr.src = 'http://script.foo.com/b.html';
ifr.style.display = 'none';
document.body.appendChild(ifr);
ifr.onload = function(){
    var doc = ifr.contentDocument || ifr.contentWindow.document;
    // 在这里操纵b.html
    alert(doc.getElementsByTagName("h1")[0].childNodes[0].nodeValue);
};
//script.foo.com上的b.html

document.domain = 'foo.com';

默认情况下document.domain 是指window.location.hostname. 你可以手动更改,但是最多只能设置为主域名。 通常,主域名就是指不带www的hostname, 比如: foo.com , baidu.com 。 如果,带上www或者其他的前缀,就是二级域名或者多级域名。通过上述设置,相同的domain之后,就可以进行同域的相关操作。
另外还可以使用iframe和location.hash,不过由于技术out了,这里就不做介绍了。

H5的CDM跨域与iframe

如果你设置的iframe的域名和你top window的域名完全不同。 则可以使用CDM(cross document messaging)进行跨域消息的传递。该API的兼容性较好 ie8+都支持.
发送消息: 使用postmessage方法
接受消息: 监听message事件

postmessage

该方法挂载到window对象上,即,使用window.postmessage()调用.
该方法接受两个参数:postMessage(message, targetOrigin):
message: 就是传递给iframe的内容, 通常是string,如果你想传object对象也可以。不过使用前请参考这一句话:

Objects listed in transfer are transferred, not just cloned, meaning that they are no longer usable on the sending side.

意思就是,希望亲爱的不要直接传Object。 如果有条件,可以使用是JSON.stringify进行转化。这样能保证不会出bug.
targetOrigin: 接受你传递消息的域名,可以设置绝对路径,也可以设置""或者"/"。 表示任意域名都行,"/"表示只能传递给同域域名。

来个栗子:

<iframe data-original="http://tuhao.com" name="sendMessage"></iframe>
//当前脚本
let ifr = window.frames['sendMessage'];
   //使用iframe的window向iframe发送message。
ifr.postmessage('give u a message', "http://tuhao.com");
//tuhao.com的脚本
window.addEventListener('message', receiver, false);
function receiver(e) {
    if (e.origin == 'http://tuhao.com') {
        if (e.data == 'give u a message') {
            e.source.postMessage('received', e.origin);  //向原网页返回信息
        } else {
            alert(e.data);
        }
    }
}

当targetOrigin接受到message消息之后,会触发message事件。 message提供的event对象上有3个重要的属性,data,origin,source.

data:postMessage传递进来的值

origin:发送消息的文档所在的域

source:发送消息文档的window对象的代理,如果是来自同一个域,则该对象就是window,可以使用其所有方法,如果是不同的域,则window只能调用postMessage()方法返回信息

属性的使用方法,如上头那个demo 说的。 很贴切, 很完美~

finally iframe

iframe的大概我们差不多了解了。 想说的是,iframe 在后面会越用越少的。但是应该是不会被w3c标准废除的。到是前端的发展,真尼玛太快了,每过一段时间,一个新工具,新技术的出现,好到是好,不过学的真心蛋疼。不过,我们要以一个大前端的姿态要求我们,md,现在不学,那什么时候学~

查看原文

赞 117 收藏 214 评论 16

吴彦欣 发布了文章 · 2018-03-27

谷歌验证器的原理及实现

two-factor-auth-prev.jpg

阅读本篇文章你可以了解到谷歌验证器的实现原理,并且可以自己使用node.js实现支持谷歌验证器的两步验证。

这两年发现身边的很多应用和网站纷纷支持两步验证,并且呼吁用户使用两步验证。

并且发现,除了Apple ID的两步验证之外,其它两步验证很多能看到谷歌验证器(Google Authenticator)的身影。

这让我产生了浓厚的兴趣,到底谷歌验证器的原理是什么,我自己能实现一个类似的验证器吗?

什么是两步验证

两步验证就是当用户输入账号密码并验证成功之后,需要额外输入一串一次性随机密码(一般是4-6位的数字),服务器以此确认登录者是用户本人。

两步验证的类型

  1. 短信验证
    短信验证也是我们最熟悉最常用的两步验证,但是我认为短信验证有有以下几个缺点:需支付运营商短信费用、短信到达延迟、短信压根就没到达而网站不允许你立刻再次重发。相信你也有过等一条短信让你抓狂的经历。
  2. 动态密码器
    玩过网易游戏的同学应该知道网易将军令,还有部分银行提供的动态密码器。我记得我以前第一次见到这个东西的时候就觉得很神奇,这东西不用联网居然就能验证用户。实际上它的实现原理和Google验证器的实现原理差不多,后面我们会详细讲到。
    two-factor-auth-jjl.jpg
  3. 口令卡
    口令卡是我认为最反人类的设计。曾经办工商银行网上银行的时候就有过一张,它上面是以矩阵的形式排列若干个字符,系统会给你一些坐标,要求你根据坐标找出相应的字符输入到系统。?%&¥#……(*#)我从来没用过。不可能的,这辈子不可能用口令卡的。。
    two-factor-auth-klk.jpg

两步验证的重要性

两步验证从用户体验的角度来说肯定是不友好的,因为用户登录或者操作一个开启两步验证的网站时,用户需要额外输入一串随机密码以确认用户是本人操作。对于大多数人来说可能觉得这操作就是多余的,麻烦的。这么麻烦不如关掉。

我建议对于比较重要的账号,如果该应用提供了两步验证,最好开启。安全第一。

我举一下我身边的一个栗子?
大概是2016年,我女朋友的Apple ID被盗。盗号者解绑了她的邮箱,改用盗号者的邮箱,导致女朋友iPhone被锁。盗号者还发邮件勒索500元解锁,我还加了盗号者qq和他砍价,砍到了200元。最后当然是找苹果客服解锁,提供了各种照片、单据和电话确认,历时2天才解锁。总体没什么损失,就是给自己生活带来一些不便。看看知乎上被盗号并且盗刷信用卡支付宝的,那才叫惨烈。
如果被盗者开启了两步验证,即使别人有你的Apple ID的账号密码,也登录不了你的账号。

另外,可能很多用户喜欢在多个网站上使用相同的密码,这样是很危险的。如果用户一个网站上的账号被盗了,其它平台的账号可能也要遭殃。毕竟“某某平台的账号系统被盗”这种事也是时有发生的。这种情况开启两步验证也是能保护其它平台的账号不被不法分子利用。

HOTP 和 TOTP

OTP

两步验证中使用的密码是一次性密码(One-Time Password 简称OTP),也称为动态口令。是使用密码技术实现的在客户端和服务器之间通过共享密钥的一种强认证技术,是增强目前静态口令认证的一种非常方便技术手段,是一种重要的两步验证认证技术。Wikipedia解释

HOTP (HMAC-Based One-Time Password Algorithm)

HOTP 是基于 HMAC 算法生成的一次性密码,也称事件同步的动态密码,是 ITEF RFC 4226 公开的算法规范, 伪代码如下:
<center>HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))</center>
客户端和服务器事先协商好一个密钥K,用于一次性密码的生成。客户端和服务器各有一个事件计数器C,并且事先将计数值同步。Truncate是将HMAC-SHA-1产生的20字节的字符串转换为若干位十进制数字的算法。

TOTP (Time-Based One-Time Password Algorithm)

TOTP 是 HOTP 的改良版,使用时间替换掉 HOTP 的事件计数器 C,也称时间同步的动态密码。详细规范见 RFC 6238,伪代码:
<center>TOTP = Truncate(HMAC-SHA-1(K,T))</center>
T = (Current Unix time - T0) / X
T0 是初试时间,默认为 0
X 是时间步长,默认30秒
官方文档中举了个栗子,假设当前unix时间=59,T0=0,X=30,则T=1
假设当前unix时间=60,T0=0,X=30,则T=2
也就是对T的值向下取整,抛弃小数的意思

实现

了解完了规范后,撸代码就简单了。

上面的算法除了HMAC-SHA-1之外就是Truncat了,贴一段Truncat的JS代码:

  // 将20字节的hmac结果转为6位数字,不够6位前面补0 
  truncat(hmac_result) {
    const offset   =  hmac_result[19].charCodeAt() & 0xf;
    const bin_code = (hmac_result[offset].charCodeAt()  & 0x7f) << 24
       | (hmac_result[offset + 1].charCodeAt() & 0xff) << 16
       | (hmac_result[offset + 2].charCodeAt() & 0xff) <<  8
       | (hmac_result[offset + 3].charCodeAt() & 0xff);
    let otp = (bin_code % 10 ** this.digit).toString();
    while (otp.length < this.digit) {
      otp = '0' + otp;
    }
    return otp;
  }

在线预览地址

代码已上传npm,支持node.js和浏览器执行
完整代码请移步我的Github https://github.com/wuyanxin/t...

安装

npm install totp.js

使用

const TOTP = require('totp.js');

// generate a base32 secret key
const key = TOTP.randomKey();
// 'GAXGGYT2OU2DEOJR'

const totp = new TOTP(key);
const code = totp.genOTP();
// '552179'
totp.verify(code)
// true

// generate Google Authenticator supported URL
totp.gaURL('handsome@totp.js', 'Totp.js')
// 'otpauth://totp/handsome@totp.js?issuer=Totp.js&secret=GAXGGYT2OU2DEOJR'

// OR
const totp2 = new TOTP(TOTP.base32.encode('your key'));
totp2.genOTP()

参考

ITEF RFC 4226
ITEF RFC 6238

欢迎转载,转载请附上原文链接
查看原文

赞 4 收藏 5 评论 0

吴彦欣 回答了问题 · 2018-03-26

gitlab runner 去执行脚本时候会出现“Host key verification failed”

大概是因为你这个key是管理员目录下,runner那个用户没有这个rsa密钥

关注 6 回答 5

吴彦欣 赞了文章 · 2017-10-17

Docker 及 GitLab CI 在前端工作流上的实践分享(二)

上一篇讲了 Docker 的使用,这篇同样通过一个简单示例,来讲讲 GitLab CI

一、什么是 GitLab CI ?

gitlab-ci 全称是 gitlab continuous integration,也就是基于 gitlab 的持续集成工具。中心思想是当每一次
push到gitlab的时候,都会触发一次脚本执行,然后脚本的内容包括了测试,编译,部署等一系列自定义的内容。
高版本的 GitLab 自带了 GitLab CI,所以不需要另外安装。

二、什么是 GitLab-Runner ?

GitLab-Runner 是脚本执行的承载者,GitLab-CI 事先注册好 GitLab-Runner,再 push 代码,对应的 Runner 就会执行你所定义的脚本。

三、安装 GitLab-Runner

Gitlab Runner安装方式有两种,一种是直接二进制文件安装,一种是基于docker镜像安装。

二进制文件安装

[1] 下载对应操作系统的二进制包,我这里使用的是mac版本,

sudo curl --output /usr/local/bin/gitlab-ci-multi-runner https://gitlab-ci-multi-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-ci-multi-runner-darwin-amd64

[2] 给 gitlab-ci-multi-runner 设置权限

sudo chmod +x /usr/local/bin/gitlab-ci-multi-runner

以上是官方安装文档,如果有问题,可以手动到版本下载列表下载对应的版本,然后复制到/usr/local/bin/目录下 ---- 反正我是自己下载安装才能用的,泪目 T T

[3] 注册runner
首先,进入到你的 gitlab 项目网页,找到 Settings -> Pipelines,然后找到对应的 urltoken

然后在终端输入

gitlab-ci-multi-runner register

然后刷新你的网页,会看到

说明注册成功了。

另外,我们可以通过 gitlab-ci-multi-runner list 查询你注册的runner ,用 gitlab-ci-multi-runner status 查看 runner 服务是否运行中。

docker镜像安装

[1] 先获取 gitlab-runner 镜像

sudo docker pull gitlab/gitlab-runner:latest

[2] 启动 gitlab-runner container

sudo docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest

[3] 注册runner

sudo docker exec -it gitlab-runner gitlab-ci-multi-runner register

注册过程略,方式同方式一步骤3.

四、配置.gitlab-ci.yml

GitLab CI的一切工作,都是由 .gitlab-ci.yml 来配置的。详细文档可以参考这里

首先,在项目根目录下创建 .gitlab-ci.yml 文件(编辑完要提交到g itlab 才能生效):

#定义 stages,用来定义工作阶段,多个 stages 会按顺序进行
stages:
  - build
  - deploy_test
  - deploy_production

# 设置缓存 
cache:
  paths:
    - node_modules/
    - dist/

# 安装依赖 before_script 会在每个 stages 执行之前运行
before_script:
- npm install

# 编译 这里对应上方 stages ,
build:
  stage: build 
  script:    # script 为要执行的命令,可以多条按顺序执行
    - npm run build

# 部署测试服务器 
deploy_test:
  stage: deploy_test
  only:    # only 定义触发分支,即只有在dev分支提交是  才执行以下命令
    - dev
  script:
    - bash scripts/dev.sh


# 部署生产服务器
deploy_production:
  stage: deploy_production
  only:
    - master
  script:
    - bash scripts/deploy.sh

配置完成后,当你在项目 push 代码到 gitlab 的时候,就会触发 gitlab-ci,然后执行你定义的代码。
可以在

running 表示正在运行,passed 表示通过了。
ps:有个容易遇到的坑,当你卡在 pending 不动的时候,可以看看你的 runner 是否设置了 '无 tag 标签也运行'
回到你的 runner,点编辑

然后,勾选第二项 Run untagged jobs

运行日志可以在这里查看

那么到这,GitLab CI 的基本使用,已经完成啦,赶快去试一下吧 :)

参考资料:
gitlab-runner 安装
gitlab ci yaml 配置
【后端】gitlab之gitlab-ci自动部署

查看原文

赞 14 收藏 16 评论 5

吴彦欣 赞了文章 · 2017-05-31

关于移动端 rem 布局的一些总结

本文作者: 文蔺
本文地址: http://www.wemlion.com/2015/a...
本文由 @文蔺 创作,转载请保留此声明。 所有权保留,请勿用于商业目的。

【资源一】基础知识恕不回顾

基础知识参考以下两篇博客:

http://isux.tencent.com/web-a...

http://www.w3cplus.com/css3/d...

【资源二】淘宝m站首页的动态实现

学习http://m.taobao.com 首页的实现。

最近读到@大漠的新文章《使用Flexible实现手淘H5页面的终端适配》,和本部分有点关系。暂且加上来以供参考。(updated 2015-11-24)

源码进行美化、解读之后,基本布局部分的代码已经被我还原出来了:(2016-01-13补充:后来才发现,早就开源在github上了)


!function(win, lib) {
    var timer,
        doc     = win.document,
        docElem = doc.documentElement,

        vpMeta   = doc.querySelector('meta[name="viewport"]'),
        flexMeta = doc.querySelector('meta[name="flexible"]'),
 
        dpr   = 0,
        scale = 0,
 
        flexible = lib.flexible || (lib.flexible = {});
 
    // 设置了 viewport meta
    if (vpMeta) {
 
        console.warn("将根据已有的meta标签来设置缩放比例");
        var initial = vpMeta.getAttribute("content").match(/initial\-scale=([\d\.]+)/);
 
        if (initial) {
            scale = parseFloat(initial[1]); // 已设置的 initialScale
            dpr = parseInt(1 / scale);      // 设备像素比 devicePixelRatio
        }
 
    }
    // 设置了 flexible Meta
    else if (flexMeta) {
        var flexMetaContent = flexMeta.getAttribute("content");
        if (flexMetaContent) {
 
            var initial = flexMetaContent.match(/initial\-dpr=([\d\.]+)/),
                maximum = flexMetaContent.match(/maximum\-dpr=([\d\.]+)/);
 
            if (initial) {
                dpr = parseFloat(initial[1]);
                scale = parseFloat((1 / dpr).toFixed(2));
            }
 
            if (maximum) {
                dpr = parseFloat(maximum[1]);
                scale = parseFloat((1 / dpr).toFixed(2));
            }
        }
    }
 
    // viewport 或 flexible
    // meta 均未设置
    if (!dpr && !scale) {
        // QST
        // 这里的 第一句有什么用 ?
        // 和 Android 有毛关系 ?
        var u = (win.navigator.appVersion.match(/android/gi), win.navigator.appVersion.match(/iphone/gi)),
            _dpr = win.devicePixelRatio;
 
        // 所以这里似乎是将所有 Android 设备都设置为 1 了
        dpr = u ? ( (_dpr >= 3 && (!dpr || dpr >= 3))
                        ? 3
                        : (_dpr >= 2 && (!dpr || dpr >= 2))
                            ? 2
                            : 1
                  )
                : 1;
 
        scale = 1 / dpr;
    }
 
    docElem.setAttribute("data-dpr", dpr);
 
    // 插入 viewport meta
    if (!vpMeta) {
        vpMeta = doc.createElement("meta");
         
        vpMeta.setAttribute("name", "viewport");
        vpMeta.setAttribute("content",
            "initial-scale=" + scale + ", maximum-scale=" + scale + ", minimum-scale=" + scale + ", user-scalable=no");
 
        if (docElem.firstElementChild) {
            docElem.firstElementChild.appendChild(vpMeta)
        } else {
            var div = doc.createElement("div");
            div.appendChild(vpMeta);
            doc.write(div.innerHTML);
        }
    }
 
    function setFontSize() {
        var winWidth = docElem.getBoundingClientRect().width;
 
        if (winWidth / dpr > 540) {
            (winWidth = 540 * dpr);
        }
 
        // 根节点 fontSize 根据宽度决定
        var baseSize = winWidth / 10;
 
        docElem.style.fontSize = baseSize + "px";
        flexible.rem = win.rem = baseSize;
    }
 
    // 调整窗口时重置
    win.addEventListener("resize", function() {
        clearTimeout(timer);
        timer = setTimeout(setFontSize, 300);
    }, false);
 
     
    // 这一段是我自己加的
    // orientationchange 时也需要重算下吧
    win.addEventListener("orientationchange", function() {
        clearTimeout(timer);
        timer = setTimeout(setFontSize, 300);
    }, false);
 
 
    // pageshow
    // keyword: 倒退 缓存相关
    win.addEventListener("pageshow", function(e) {
        if (e.persisted) {
            clearTimeout(timer);
            timer = setTimeout(setFontSize, 300);
        }
    }, false);
 
    // 设置基准字体
    if ("complete" === doc.readyState) {
        doc.body.style.fontSize = 12 * dpr + "px";
    } else {
        doc.addEventListener("DOMContentLoaded", function() {
            doc.body.style.fontSize = 12 * dpr + "px";
        }, false);
    }
  
    setFontSize();
 
    flexible.dpr = win.dpr = dpr;
 
    flexible.refreshRem = setFontSize;
 
    flexible.rem2px = function(d) {
        var c = parseFloat(d) * this.rem;
        if ("string" == typeof d && d.match(/rem$/)) {
            c += "px";
        }
        return c;
    };
 
    flexible.px2rem = function(d) {
        var c = parseFloat(d) / this.rem;
         
        if ("string" == typeof d && d.match(/px$/)) {
            c += "rem";
        }
        return c;
    }
}(window, window.lib || (window.lib = {}));

注意:
淘宝首页在iPhone4上设置的initial-scale是0.5(其他尺寸类似)。

因此,这句在iPhone4上得出的结果是640:

var winWidth = docElem.getBoundingClientRect().width;  

正是因为淘宝这种独特的设置,使得 ios 上 1px边框的问题完美解决(1px变2px, 又被 initial-scale=0.5 缩小了一半)。

【资源三】常规情况下js根据屏幕宽度动态计算

使用js动态计算:


!(function(doc, win) {
    var docEle = doc.documentElement,
        evt = "onorientationchange" in window ? "orientationchange" : "resize",
        fn = function() {
            var width = docEle.clientWidth;
            width && (docEle.style.fontSize = 20 * (width / 320) + "px");
        };
     
    win.addEventListener(evt, fn, false);
    doc.addEventListener("DOMContentLoaded", fn, false);
 
}(document, window));

【资源四】媒体查询较密集的断点

使用css3 media query 实现

@media screen and (min-width: 320px) {
    html {font-size: 14px;}
}
 
@media screen and (min-width: 360px) {
    html {font-size: 16px;}
}
 
@media screen and (min-width: 400px) {
    html {font-size: 18px;}
}
 
@media screen and (min-width: 440px) {
    html {font-size: 20px;}
}
 
@media screen and (min-width: 480px) {
    html {font-size: 22px;}
}
 
@media screen and (min-width: 640px) {
    html {font-size: 28px;}
}

【资源五】强大的单位——vw

使用单位 vw 实现动态计算。

html {
    font-size: 31.25vw; /* 表达式:100*100vw/320 */
}

不过考虑到国内兼容性的问题,还是结合媒体查询来使用比较好。(媒体查询的断点暂时是借用上面的例子)

图片描述

@media screen and (min-width: 320px) {
    html {
        font-size: 100px;
    }
}
 
@media screen and (min-width: 360px) {
    html {
        font-size: 112.5px;
    }
}
 
@media screen and (min-width: 400px) {
    html {
        font-size: 125px;
    }
}
 
@media screen and (min-width: 440px) {
    html {
        font-size: 137.5px;
    }
}
 
@media screen and (min-width: 480px) {
    html {
        font-size: 150px;
    }
}
 
@media screen and (min-width: 640px) {
    html {
        font-size: 200px;
    }
}
 
html {
    font-size: 31.25vw;
}

【总结】

对以上种种方法的综合:

1、meta:viewport, 还是initial-scale为 1;

2、320px屏幕下,把页面根元素html的字体大小设置为50px;

3、鉴于我们拿到的设计图目前是640px宽的基准,这样我们就不用每次自己除以2了,直接在PS中量就好;

4、宽度什么的最好还是用百分比处理;涉及到高度、字体大小之类的则用rem。

eg:
设计稿上,div高度为40px;那么css就是 div {height: 0.4rem;}

结果就只剩下一步转换:设计稿上量的长度转化为小数。 50% => 0.5 这种计算,不要太简单。。。

【方法一】纯粹css,支持calc函数的动态计算;不支持的用css媒体查询断点,优雅降级。

@media screen and (min-width: 320px) {
    html {
        font-size: 50px;
    }
}
 
@media screen and (min-width: 360px) {
    html {
        font-size: 56px;
    }
}
 
@media screen and (min-width: 400px) {
    html {
        font-size: 63px;
    }
}
 
@media screen and (min-width: 440px) {
    html {
        font-size: 69px;
    }
}
 
@media screen and (min-width: 480px) {
    html {
        font-size: 75px;
    }
}

/**
 * 2016-01-13 订正
 * 做适当限制
 * 大于640的屏幕 固定为100px
 * 同时需要对body或者最外层wrapper做max-width: 640px的限制
 */
/*
@media screen and (min-width: 640px) {
    html {
        font-size: 100px;
    }
}

html {
    font-size: 15.625vw;
}
*/

html {
    font-size: 15.625vw;
}

@media screen and (min-width: 640px) {
    html {
        font-size: 100px;
    }
}

【方法二】脚本动态计算

大前提:

1、initial-scale 为 1;

2、在项目css中(注意不要被公共的base、common之类的影响了,资源加载顺序也是蛮重要的),先把html的fontSize设置为 50px(或者加上媒体查询代码), 避免加载未完成时候样式错乱;


/* css */
html {font-size: 50px;}
/* javascript */

!(function(win, doc){
    function setFontSize() {
        // 获取window 宽度
        // zepto实现 $(window).width()就是这么干的
        var winWidth =  window.innerWidth;
        // doc.documentElement.style.fontSize = (winWidth / 640) * 100 + 'px' ;
        
        // 2016-01-13 订正
        // 640宽度以上进行限制 需要css进行配合
        var size = (winWidth / 640) * 100;
        doc.documentElement.style.fontSize = (size < 100 ? size : 100) + 'px' ;
    }
 
    var evt = 'onorientationchange' in win ? 'orientationchange' : 'resize';
    
    var timer = null;
 
    win.addEventListener(evt, function () {
        clearTimeout(timer);
 
        timer = setTimeout(setFontSize, 300);
    }, false);
    
    win.addEventListener("pageshow", function(e) {
        if (e.persisted) {
            clearTimeout(timer);
 
            timer = setTimeout(setFontSize, 300);
        }
    }, false);
 
    // 初始化
    setFontSize();
 
}(window, document));

嗯。。。

就这么愉快地结束了。。。

不知道解读了某宝首页的一点点代码,然后发在这里,会不会有什么后果。。。

==================================================

2016年1月13日补充

写过这篇博客之后,又陆续读过几篇关于布局的文章。

具体已经忘了,大约是大漠的文章,还有一篇应该是搜车前端的博文,另外应该还有关于手淘首页的分析的文章。

另外,自己也用rem布局实践过几个项目。

不得不说,个人觉得rem布局现在已经可以放弃了。flex布局已经很好用了,早已有之的百分比布局等稍用点心思也并不难。

这篇博客一直想改。但懒惰总是占据着我的身体。

最后再说下,字体大小自适应是错误的,字体大小自适应是错误的,字体大小自适应是错误的。

rem 布局,可以告别了。

迎接 flex 布局吧。

=========================================

写在最后

这篇博客写于半年前,那时候还是个刚毕业的菜鸟。

偶尔有点想法,看了一些大牛的文章,有了这篇博客。

这也是半年来唯一一篇产出。

5k的浏览量,95收藏,13推荐,已经让我很惊讶了。

谢谢各路大神们的关注。

半年来感受到的前端大环境变化还是很大。虽然在公司没有太多变化,但眼睛总得看着世界吧。

接下来,还得继续学习。

由于手上没什么项目,一直想探索出一套自己的自动化流程,但到现在也只是积累了许多版的草稿。

nodejs方面也得有所探索,nodejs 再加上 shelljsyargs 用起来是真的很爽。(鸣谢阮大神的文章

算是年终总结了。在前端的路上继续走吧。

=========================================

一点想法:评论区的回复

媒体查询和js动态计算是两种方式。

首先,支持 CSS3 calc方法 和 remvw单位的浏览器下,只需要html {font-size: 15.625vw;}这样一句就好,另外加个媒体查询限制下。

之前的一大堆密集的断点只是为了hack不支持calc或者calc的情况。其次,js动态设置html的font-size,只要浏览器支持rem单位即可。

为什么会考虑到密集的mq断点呢,因为当时还在考虑文字大小的自适应问题。

实践证明,字体大小自适应是一种错误的想法。

移动开发在必要情况的下,可以适当使用mq来调整字体大小,但做成完全自适应则是一种存在问题的做法。

因此,这里提到的 calcvhrem配合的做法,最好只用来做布局的工作。js动态计算也是类似,更适合做布局。

更新

Articles on responsive font:

查看原文

赞 71 收藏 420 评论 51

吴彦欣 赞了文章 · 2017-05-05

Css Sprite 图片等比缩放图片大小(background-size优化Sprites图显示)

在一个项目的开发过程中用到了css Sprite,有这样一个需求:目标样式大小是32px * 23px大小的图片作为图标。 如图所示:

图片描述

但是,给我的背景图片是这样的一张图,256px * 46px:

图片描述

在我们的实例中,我们使用底部菜单大小32px * 23px.为了保证普通显屏设备的图像显示,在这个图像的基础上做了一半的缩小。

在最初的CSS样式中,background-position的属性值都不需要进行任何设置,因为他们的默认值都是“0”,如下所示:

.x-navbar [class^='x-icon-'] {
background-image: url(img/icon_navbar_new_year.png);
width: 32px;
height: 23px;
} 

因此,最关键的问题是,我们怎么知道background-size属性值要设置为多少?

这里有一个公式:
高分辨率图像宽度 / 目标图像宽度 = X
原始Sprites图像宽度 / x = background-size的宽度值

我们高分辨率下的图标是256px x 46px;
我们目标图像的宽度是“64px”;
我们Sprites图像的总宽度是“256px”

根据前面的公式 可以得知
64/32 = 2
256/2 = 128
最后的一件事情。我们只计算了背景图像的宽度值,为了确保背景图像缩放比例正常,我们将“height”值设置为“auto”。当然你也可以设置相的的值(设置background-sizer的宽度为auto,高度为具体值),但我发现设置宽度会比较容易。

.x-navbar [class^='x-icon-'] {
    background-size: 128px auto;
}

然后就是根据图片的位置坐标进行显示了:

.x-icon-shouye {background-position: 0 0;}
.x-icon-dingdan {background-position: -32px 0;}
.x-icon-gouwuche {background-position: -64px 0;}
.x-icon-gengduo {background-position: -96px 0;}

这样就实现了图中的效果了!

查看原文

赞 5 收藏 15 评论 0

吴彦欣 回答了问题 · 2017-04-05

如何将已经写好的node.js项目使用keystone.js来管理后台?

keystone的model是要按他规范建的

关注 2 回答 1

吴彦欣 赞了文章 · 2017-02-22

用VUEJS做一个网易云音乐

前言:自己学习VUEJS也一段时间,但一直没有做出来一东西。我自己一直喜欢用网易云音乐app,于是乎就做了这个app。

技术栈

  • vue全家桶 (vue vue-router vuex)

  • axios

  • Muse-UI(一个基于Vue2.x的material design 风格UI框架)

功能与思路分析

我之前学习JS的时候对Html5 audio研究过,也写过一些例子,那时的功能并不是很全面。在写这个程序之前,我好好的查阅了当前的HTML5中的audio标签,发现园子上一位园友总结的很不错(这里)。于是就先把网易云音乐最基本的功能实现,歌单部分(这也是我喜欢网易云音乐的原因这一),然后实现音乐的上一曲、下一曲,播放、暂停。列表功能。

后台

后台采用.net做为后台提供系统请求所用的API(源码),原理很简单就是用.net伪装成一个客户端去访问网易云音乐的API然后,把返回的json数据转发出来。同时服务端做下跨域处理。

核心代码:

/// <summary>
/// 请求网易云音乐接口
/// </summary>
/// <typeparam name="T">要请求的接口类型</typeparam>
/// <param name="config">要请求的接口类型的对象</param>
/// <returns>请求结果(JSON)</returns>
public static string Request<T>(T config) where T : RequestData, new()
{
    // 请求URL
    string requestURL = config.Url;
    // 将数据包对象转换成QueryString形式的字符串
    string @params = config.FormData.ParseQueryString();
    bool isPost = config.Method.Equals("post", StringComparison.CurrentCultureIgnoreCase);

    if (!isPost)
    {
        // get方式 拼接请求url
        string sep = requestURL.Contains('?') ? "&" : "?";
        requestURL += sep + @params;
    }

    HttpWebRequest req = (HttpWebRequest)WebRequest.Create(requestURL);
    req.Accept = "*/*";
    req.Headers.Add("Accept-Language", "zh-CN,zh;q=0.8,gl;q=0.6,zh-TW;q=0.4");
    // 如果服务端启用了GZIP,那么下面必须解压,否则一直乱码。
    // 参见:http://www.crifan.com/set_accept_encoding_header_to_gzip_deflate_return_messy_code/
    req.Headers.Add("Accept-Encoding", "gzip,deflate,sdch");
    req.ContentType = "application/x-www-form-urlencoded";
    req.KeepAlive = true;
    req.Host = "music.163.com";
    req.Referer = "http://music.163.com/search/";
    req.UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/33.0.1750.152 Safari/537";            
    // 设置cookies
    req.Headers["Cookie"] = "appver=1.5.2";
    req.Method = config.Method;
    req.AutomaticDecompression = DecompressionMethods.GZip;
    if (isPost)
    {
        // 写入post请求包
        byte[] formData = Encoding.UTF8.GetBytes(@params);
        // 设置HTTP请求头  参考:https://github.com/darknessomi/musicbox/blob/master/NEMbox/api.py          
        req.GetRequestStream().Write(formData, 0, formData.Length);
    }            
    // 发送http请求 并读取响应内容返回
    return new StreamReader(req.GetResponse().GetResponseStream(), Encoding.GetEncoding("UTF-8")).ReadToEnd();
}

vuejs部分

项目结构

├── index.html
├── main.js
├── api
│   └── ... # 抽取出API请求
├── components
│   ├── playBar.vue
│   └── ...
└── store
│    └── index.js        # 整个项目的vuex部分
└── router
│   └── router.js        # 整个项目的路由
└── utils                # 一些工具类模块
│
└── views                # 项目中的一些route-view

说项目的路由之前,先来看一张效果图

对于整个项目来说:视图区别在于顶部导航,下面的bar的是否出来取决于,当前系统列表中是否有歌曲,如果有就会出现。

router.js核心部分

const router = new VueRouter({
  mode: 'history',
  routes: [{
    path: '/index',
    component: require('../views/index'),
    children: [
      {
        path: 'rage',
        component: require('../views/rage')
      },
      {
        path: 'songList',
        component: require('../views/songList')
      },
      {
        path: 'leaderBoard',
        component: require('../views/leaderBoard')
      },
      {
        path: 'hotSinger',
        component: require('../views/hotSinger')
      }
    ]
  }, {
    name: 'playerDetail',
    path: '/playerDetail/:id',
    component: require('../views/playerDetail')
  }, {
    path: '/playListDetail/:id',
    name: 'playListDetail',
    component: require('../views/playListDetail')
  }, {
    path: '*', redirect: '/index/rage'
  }],
  // 让每个页面都滚动到顶部,改变模式为mode: history
  scrollBehavior (to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  }
})

vuex部分

这部分,主要是歌曲这一块,因为不同的页面有不同的使用到了歌曲信息,把把这部分数据放到vuex中做统一的数据处理!
sotre/index.js

const store = new Vuex.Store({
  state: {
    audio: {
      'id': 0,
      'name': '歌曲名称',
      'singer': '演唱者',
      'albumPic': '/static/player-bar.png',
      'location': '',
      'album': ''
    },
    lyric: '正在加载中。。',
    currentIndex: 0, // 当前播放的歌曲位置
    playing: false, // 是否正在播放
    loading: false, // 是否正在加载中
    showDetail: false,
    songList: [],    // 播放列表
    currentTime: 0,
    tmpCurrentTime: 0,
    durationTime: 0,
    bufferedTime: 0,
    change: false   // 判断是更改的时间还是播放的时间
  },
  getters: {
    audio: state => state.audio,
    playing: state => state.playing,
    loading: state => state.loading,
    showDetail: state => state.showDetail,
    durationTime: state => state.durationTime,
    currentIndex: state => state.currentIndex,
    bufferedTime: state => state.bufferedTime,
    tmpCurrentTime: state => state.tmpCurrentTime,
    songList: state => state.songList,
    change: state => state.change,
    currentTime: state => state.currentTime,
    prCurrentTime: state => {
      return state.currentTime / state.durationTime * 100
    },
    prBufferedTime: state => {
      return state.bufferedTime / state.durationTime * 100
    }
  },
  mutations: {
    play (state) {
      state.playing = true
    },
    pause (state) {
      state.playing = false
    },
    toggleDetail (state) {
      state.showDetail = !state.showDetail
    },
    setAudio (state) {
      state.audio = state.songList[state.currentIndex - 1]
    },
    setAudioIndex (state, index) {
      state.audio = state.songList[index]
      state.currentIndex = index + 1
    },
    removeAudio (state, index) {
      state.songList.splice(index, 1)
      state.audio = state.songList[index - 1]
      state.currentIndex = state.currentIndex - 1
      if (state.songList.length === 0) {
        state.audio = {
          'id': 0,
          'name': '歌曲名称',
          'singer': '演唱者',
          'albumPic': '/static/player-bar.png',
          'location': '',
          'album': ''
        }
        state.playing = false
      }
    },
    setChange (state, flag) {
      state.change = flag
    },
    setLocation (state, location) {
      state.audio.location = location
    },
    updateCurrentTime (state, time) {
      state.currentTime = time
    },
    updateDurationTime (state, time) {
      state.durationTime = time
    },
    updateBufferedTime (state, time) {
      state.bufferedTime = time
    },
    changeTime (state, time) {
      state.tmpCurrentTime = time
    },
    openLoading (state) {
      state.loading = true
    },
    closeLoading (state) {
      state.loading = false
    },
    resetAudio (state) {
      state.currentTime = 0
    },
    playNext (state) { // 播放下一曲
      state.currentIndex++
      if (state.currentIndex > state.songList.length) {
        state.currentIndex = 1
      }
      state.audio = state.songList[state.currentIndex - 1]
    },
    playPrev (state) { // 播放上一曲
      state.currentIndex--
      if (state.currentIndex < 1) {
        state.currentIndex = state.songList.length
      }
      state.audio = state.songList[state.currentIndex - 1]
    },
    addToList (state, item) {
      var flag = false
      state.songList.forEach(function (element, index) { // 检测歌曲重复
        if (element.id === item.id) {
          flag = true
          state.currentIndex = index + 1
        }
      })
      if (!flag) {
        state.songList.push(item)
        state.currentIndex = state.songList.length
      }
    },
    setLrc (state, lrc) {
      state.lyric = lrc
    }
  },
  // 异步的数据操作
  actions: {
    getSong ({commit, state}, id) {
      commit('openLoading')
      Axios.get(api.getSong(id)).then(res => {
        // 统一数据模型,方便后台接口的改变
        var url = res.data.data[0].url
        commit('setAudio')
        commit('setLocation', url)
      })
    },
    getLrc ({commit, state}, id) {
      commit('setLrc', '[txt](加载中。。。')
      Axios.get(api.getLrc(id)).then(res => {
        // 1、先判断是否有歌词
        if (res.data.nolyric) {
          commit('setLrc', '[txt](⊙0⊙) 暂无歌词')
        } else {
          console.log(res.data.lrc.lyric)
          commit('setLrc', res.data.lrc.lyric)
        }
      })
    }
  }
})

最后上点项目截图

github项目地址:https://github.com/javaSwing/NeteaseCloudWebApp

目前只完成app歌单部分,也是最核心的部分。这个项目会一直更新!如果觉的不错就给个star吧

查看原文

赞 38 收藏 310 评论 64

吴彦欣 关注了用户 · 2017-02-16

平凡 @pingfan_58a471333f1db

关注 1

吴彦欣 赞了回答 · 2017-02-14

解决写一个正则表达式,获取带括号的数字,比如 '12[3]45[6]7' 期望结果是['3', '6']

'12[3]45[6]78]123'.match(/.*?\[\d+\].*?/g).map(s=>{return s.replace(/.*?\[(\d+)\].*?/,'$1')})
// ["3", "6"]

关注 8 回答 3

吴彦欣 赞了回答 · 2017-02-14

解决写一个正则表达式,获取带括号的数字,比如 '12[3]45[6]7' 期望结果是['3', '6']

> var num = [];
undefined
> '12[343]45[6]7'.replace(/\[(\d+)\]/g, function($1, $2) {num.push($2); return $1;})
'12[343]45[6]7'
> num
[ '343', '6' ]

关注 8 回答 3

吴彦欣 赞了回答 · 2017-02-14

解决写一个正则表达式,获取带括号的数字,比如 '12[3]45[6]7' 期望结果是['3', '6']

'12[3]45[6]7'.match(/\d(?=\])/g)
//[ '3', '6' ]

/\b\d(?=\])/g


/(?<=\[)\d(?=\])/g

选一个能用的~

关注 8 回答 3

吴彦欣 提出了问题 · 2017-02-14

解决写一个正则表达式,获取带括号的数字,比如 '12[3]45[6]7' 期望结果是['3', '6']

如何匹配'[d]',但是结果不包含'[]'

> '12[3]45[6]7'.match(/\[\d\]/g)
[ '[3]', '[6]' ]

如何修改正则表达式使结果等于 [ '3', '6' ]

关注 8 回答 3

吴彦欣 发布了文章 · 2017-01-03

搭建本地HTTPS测试环境

生成证书

1. 使用openssl生成密钥privkey.pem:

openssl genrsa -out privkey.pem 1024/2038

2. 使用密钥生成证书server.pem:

openssl req -new -x509 -key privkey.pem -out server.pem -days 365

证书信息可以随便填或者留空,只有Common Name要根据你的域名填写。

以我的个人网站为例
Common Name (e.g. server FQDN or YOUR name) []: wuyanxin.com

也可以通过*.yourdomain.com来匹配你的二级域名

配置nginx

server {
    listen 443;
  server_name youdomain.com;

  ssl on;
    ssl_certificate /path/to/server.pem;
    ssl_certificate_key /path/to/privkey.pem;
  
  ...
}

验证配置,重启nginx

  $ sudo nginx -t && sudo nginx -s reload

信任证书

这时访问你的站点会得到一个不安全提示,因为你的证书不是一个有效的证书颁发机构颁发的。
这时需要我们手动信任证书。

1 点击地址栏前面的红色感叹号,再点击“详细信息”

2 点击“View certification”

3 将上图中的证书拖拽到桌面,会在桌面生成一个“wuyanxin.com.cer”文件

4 双击打开,并输入密码

5 在列表中找到你的证书,双击打开

6 将证书改为“始终信任”,然后关闭窗口,输入密码

7 刷新页面,你将获得一个绿色小锁(valid HTTPS)


参考

http://www.cnblogs.com/AloneS...
http://alphayang.github.io/20...

查看原文

赞 12 收藏 38 评论 4