savokiss

savokiss 查看完整档案

北京编辑郑州轻工业学院  |  网络工程 编辑腾讯科技  |  前端开发工程师 编辑 savokiss.com 编辑
编辑

You know nothing, SpongeBob.

公众号:码力全开 (codingonfire)

尽我所能为大家带来有用的东西~

个人动态

savokiss 赞了文章 · 10月23日

uni-app微信小程序接入人脸核身SDK


这几天使用uni-app开发某银行的一个微信小程序,需要集成接入腾讯云的人脸核身SDK,如上图所示,记录下整合接入过程及踩的一些坑,帮助后面需要的朋友们。关于uni-app接入人脸核身SDK有不懂的地方可以在下面提问,看到会及时回复。

申请服务

不是所有的企业都能够申请的,需要符合以下行业要求的客户才能申请
政务:政府机构或事业单位
金融:银行、保险
医疗:公立医疗机构
运营商:电信运营商
教育:公立教育机构
交通:航空、客运、网约车、交通卡、共享交通、轨道交通、租车
旅游:酒店
物流:快递、邮政、物流

由于SDK会调用小程序原生的wx.startFacialRecognitionVerify方法,所以总共得申请2个服务
SDK服务申请人脸核身服务
小程序查看申请流程(需要发送邮件申请,使用该服务的小程序的appid,后面开发也是用的这个)
重要的事情说3遍
以上这2个服务都需要申请,缺一不可。
以上这2个服务都需要申请,缺一不可。
以上这2个服务都需要申请,缺一不可。

下载SDK

由于不是我申请的,所以怎么下载我也不知道,听群里的人说的是SDK腾讯云下发给客户的。

SDK目录结构

image.png

SDK接入

参考腾讯云文档的接入方法:https://cloud.tencent.com/document/product/1007/31071
文档是针对原生小程序写的,所以页面引入的方法有所不同
由于uni-app不支持直接引入小程序的原生页面,所以这里能想到的就是将它当作成一个微信小程序的组件,然后uni-app的页面引入这个组件

解压引入SDK

在uni-app项目中新建wxcomponents目录,将SDK解压后放到该目录
image.png
pages.jsonglobalStyle中全局引入小程序的组件,注意引用的路径

"usingComponents": {
  "verify-mpsdk": "/wxcomponents/verify_mpsdk/index/index"
}

image.png

新建人脸核身页面

pages中新建人脸核身的页面face(名字可以随意,根据自己的需要起名),
pages.json中配置页面
image.png
face页面中引入verify-mpsdk组件
image.png
最终的人脸核身的页面访问就是/pages/face/face

初始化SDK

在需要的页面初始化SDK,如有个页面需要点击按钮进行人脸核身,就在这个页面进行初始化。
这个直接照着文档快速入门中的来就行了,这里就直接使用uni-app默认的index页面,
适当修改下即可,大概代码如下:

<template>
  <view class="content">
    <button type="primary"
      @tap="gotoVerify">
      进入人脸核身
    </button>
  </view>
</template>

<script>
    export default {
        data() {
            return {
                BizToken: ''
            }
        },
        onLoad() {
            // 初始化慧眼实名核身组件
            const Verify = require('@/wxcomponents/verify_mpsdk/main.js')
            Verify.init()
        },
        methods: {
            // 单击进入人脸核身按钮时,触发该函数
            gotoVerify () {
                this.BizToken = '' // 这里需要我们去客户后端调用DetectAuth接口获取BizToken
                // 调用实名核身功能
                wx.startVerify({
                        data: {
                            token: this.BizToken // BizToken
                        },
                        success: (res) => { // 验证成功后触发
                                // res 包含验证成功的token, 这里需要加500ms延时,防止iOS下不执行后面的逻辑
                                setTimeout(() => {
                                    // 验证成功后,拿到token后的逻辑处理,具体以客户自身逻辑为准
                                    console.log(res)
                                }, 500)
                        },
                        fail: (err) => {  // 验证失败时触发
                                // err 包含错误码,错误信息,弹窗提示错误
                                setTimeout(() => {
                                        console.log(err)
                                        wx.showModal({
                                            title: "提示",
                                            content: err.ErrorMsg,
                                            showCancel: false
                                        })
                                }, 500)
                        }
                })
            }
        }
    }
</script>

注意下这里的BizToken,需要调用后端服务接口来获取,
需要后端的同学调用腾讯云提供的DetectAuth来返回前端需要的BizToken
调试开发阶段我们可以先通过腾讯云提供的工具
API 3.0 Explorer
直接来获取这个BizToken
如果服务申请成功后控制台一般能找到SecretIdSecretKeyRuleId
注意EndpointRegion选择的地区得保持和申请时选择的地区一致。
填写完成后点击在线调用中的发送请求按钮,如果填的都对的话返回信息里面会有BizToken
拿到BizToken后就可以直接使用了,修改下上面的代码:
xxxxxxxxxxxxxxxxx就是拿到的BizToken

this.BizToken = 'xxxxxxxxxxxxxxxxx' // 这里需要我们去客户后端调用DetectAuth接口获取BizToken

image.png

开发调试

上面都做完后就可以进行调试了
需要先在项目中manifest.json中配置上小程序的appid,这个appid就是上面申请服务中的appid,不然无法开启调试。
image.png
然后运行到微信开发工具(这里就不多说了),如果提示不是开发人员,就让该appid的管理员将你加到开发组里面就行了。
运行成功后点击开发者工具的真机调试,扫描二维码开启真机调试模式。
接下来就是踩坑了,会出现各种问题。

踩坑及解决方法

Component is not found in path

这里开发者工具里面都是显示正常的,不会报这个错,
手机扫码进入调试后控制台会出现这个报错,
提示组件找不到,但是我们的路径都是对的,
Component is not found in path "wxcomponents/verify_mpsdk/index/index"
image.png
问题出在这里将verify_mpsdk当成自定义组件了,
小程序自定义组件引入的时候需要在文件JSON中指定"component": true
找到wxcomponents\verify_mpsdk\index\index.json文件,加入"component": true即可
重新开启调试扫码后上面的报错就没了。 
image.png

navigateTo:fail page

点击按钮调用gotoVerify后会报一个页面找不到的错
navigateTo:fail page "verify_mpsdk/index/index?isNotice=false" is not found
image.png
SDK默认的是跳转验证页面的地址是verify_mpsdk/index/index
文档找了半天也没找到相应的配置地址,最后在SDK里面搜索找到了这个地址。
所以只需要把这个地址改成我们所需要的地址就行了。
找到wxcomponents\verify_mpsdk\main.js,里面搜索verify_mpsdk/index/index,
找到后修改成上面人脸核身页面的地址pages/face/face
保存后重试就能跳转到人脸核身的页面了。

无操作、无报错大坑

进入人脸核身的页面后会发现啥操作都没,控制台也没报错,
image.png
一度认为我自己弄的有问题,搞了好久也没弄好,也提了个工单(腾讯云工单反馈率还是很快的,几分钟后就有人回复了,这点赞一个),
将代码和相关操作在工单里描述了下,对方也觉得的没问题,按照快速入门的代码应该是没问题的,对方也没找到啥问题,就让我加了一个腾讯云慧眼小助手的微信,
本想着下午加人家看看啥问题的,中午吃完饭闲着的时候将SDK里面的文件都格式化后终于在index.js里面找到问题所在了。
wxcomponents\verify_mpsdk\index\index.js文件中有个onLoad生命周期,
image.png
正常原生微信小程序进入到这个页面的时候会执行onLoad里面的代码,
但是我们上面将这个SDK当作是一个自定义组件了,
在uni-app中组件是不存在onLoad这个生命周期的,这个是页面所属的生命周期。
找到问题所在就好解决了,我们可以在人脸核身的页面pages/face/face手动执行onLoad
修改下pages/face/face的代码,如下:

<template>
  <view class="face">
    <verify-mpsdk ref="verifyMpsdk"></verify-mpsdk>
  </view>
</template>

<script>
    export default {
        data() {
            return {
                
            }
        },
        onLoad(i) {
            // 页面onLoad的时候手动调用
            this.$refs.verifyMpsdk.onLoad(i)
        }
    }
</script>

保存后重试,就能正常显示了
image.png

SDK图片异常

点击快速验证进入下一步及后面的步骤的时候发现,页面的图片都挂掉了不显示,
一开始我一直用的真机调试,页面上也不会出现破图,控制台也不会报图片异常的错误,
导致我不知道怎么进行拍摄身份证,以为会自动识别身份证然后自动下一步,
最后在开发者工具里面跑了一遍才知道是图片找不到了,然后拍照的图片按钮自然也就显示不了了。
image.png
image.png
最后在SDK里面搜索/verify_mpsdk/images,在下面文件中找到关键词,
wxcomponents\verify_mpsdk\templates\ocr\ocr.wxml
image.png
既然这种形式导致运行的时候图片找不到,我们可以把SDK所用的图片都复制到项目的static目录里
static中新建verify_mpsdk目录,将SDK中的图片即wxcomponents\verify_mpsdk\images
复制到static\verify_mpsdk中,最终形成以下目录形式
image.png
最后将wxcomponents\verify_mpsdk\templates\ocr\ocr.wxml中的/verify_mpsdk/images批量替换成
/static/verify_mpsdk/images后重试即可,然后就都正常了。
image.png
image.png

完整流程

最后用真机调试完整跑一把
image.png
image.png
image.png
image.png

备注:如果最上面的wx.startFacialRecognitionVerify服务没有申请到此时点击下一步的会弹出一个无权限的弹窗无法进行下一步

image.png
image.png

这里就是活体人脸检测了,需要将脸对准框框,点击开始后需要读几个数字,

image.png

最后验证通过后会回到之前的页面(调用gotoVerify()方法的页面),
验证成功后,会拿到一个BizToken
可以在wx.startVerify回调函数success中打印自行查看。
拿到BizToken后可以调用后端的接口,后端通过调用 GetDetectInfo 接口获取并返回本次核身的详细信息,包括身份证上的信息和身份证证图片等信息。
前端拿到这些信息后根据自己的程序需要做处理。

结语

整合过程中遇到不少问题,百度加google也找不到相关的详细信息,
人脸核身的相关文档都很简单,出现问题后无从下手,只能慢慢自己摸索解决了,
最后写篇文章记录下整个过程,也能帮到后面需要集成这个SDK的朋友们。

查看原文

赞 29 收藏 22 评论 8

savokiss 赞了文章 · 10月23日

基于 qiankun 的 CMS 应用微前端实践

图片来源:https://zhuanlan.zhihu.com/p/...
本文作者:史志鹏

前言

LOOK 直播运营后台工程是一个迭代了 2+ 年,累计超过 10+ 位开发者参与业务开发,页面数量多达 250+ 的“巨石应用”。代码量的庞大,带来了构建、部署的低效,此外该工程依赖内部的一套 Regularjs 技术栈也已经完成了历史使命,相应的 UI 组件库、工程脚手架也被推荐停止使用,走向了少维护或者不维护的阶段。因此, LOOK 直播运营后台基于 React 新建工程、做工程拆分被提上了工作日程。一句话描述目标就是:新的页面将在基于 React 的新工程开发, React 工程可以独立部署,而 LOOK 直播运营后台对外输出的访问地址期望维持不变。
本文基于 LOOK 直播运营后台的微前端落地实践总结而成。主要介绍在既有“巨石应用”、 Regularjs 和 React 技术栈共存的场景下,使用微前端框架 qiankun ,实现CMS应用的微前端落地历程。
关于 qiankun 的介绍,请移步至官方查阅,本文不会侧重于介绍有关微前端的概念。

一.背景

1.1 现状

  1. 如上所述,存在一个如下图所示的 CMS 应用,这个应用的工程我们称之为 liveadmin ,访问地址为:https://example.com/liveadmin,访问如下图所示。

  1. 我们希望不再在 liveadmin 旧工程新增新业务页面,因此我们基于内部的一个 React 脚手架新建了一个称为 increase 的新工程,新的业务页面都推荐使用这个工程开发,这个应用可以独立部署独立访问,访问地址为:https://example.com/lookadmin,访问如下图所示:

1.2 目标

我们希望使用微前端的方式,集成这两个应用的所有菜单,让用户无感知这个变化,依旧按照原有的访问方式 https://example.com/liveadmin,可以访问到 liveadmin 和 increase 工程的所有页面。
针对这样一个目标,我们需要解决以下两个核心问题:

  1. 两个系统的菜单合成展示;
  2. 使用原有访问地址访问两个应用的页面。

对于第 2 个问题,相信对 qiankun 了解的同学可以和我们一样达成共识,至于第 1 个问题,我们在实践的过程中,通过内部的一些方案得到解决。下文在实现的过程会加以描述。这里我们先给出整个项目落地的效果图:

可以看到, increase 新工程的一级菜单被追加到了 liveadmin 工程的一级菜单后面,原始地址可以访问到两个工程的所有的菜单。

1.3 权限管理

说到 CMS,还需要说一下权限管理系统的实现,下文简称 PMS。

  1. 权限:目前在我们的 PMS 里定义了两种类型的权限:页面权限(决定用户是否可以看到某个页面)、功能权限(决定用户是否可以访问某个功能的 API )。前端负责页面权限的实现,功能权限则由服务端进行管控。
  2. 权限管理:本文仅阐述页面权限的管理。首先每个前端应用都关联一个 PMS 的权限应用,比如 liveadmin 关联的是 appCode = live_backend 这个权限应用。在前端应用工程部署成功后,通过后门的方式推送前端工程的页面和页面关联的权限码数据到 PMS。风控运营在 PMS 系统中找到对应的权限应用,按照角色粒度分配页面权限,拥有该角色的用户即可访问该角色被分配的页面。
  3. 权限控制:在前端应用被访问时,最外层的模块负责请求当前用户的页面权限码列表,然后根据此权限码列表过滤出可以访问的有效菜单,并注册有效菜单的路由,最后生成一个当前用户权限下的合法菜单应用。

二.实现

2.1 lookcms 主应用

  1. 首先,新建一个 CMS 基础工程,定义它为主应用 lookcms,具有基本的请求权限和菜单数据、渲染菜单的功能。

入口文件执行以下请求权限和菜单数据、渲染菜单的功能。

// 使用 Redux Store 处理数据
const store = createAppStore(); 
// 检查登录状态
store.dispatch(checkLogin());
// 监听异步登录状态数据
const unlistener = store.subscribe(() => {
 unlistener();
 const { auth: { account: { login, name: userName } } } = store.getState();
 if (login) { // 如果已登录,根据当前用户信息请求当前用户的权限和菜单数据
 store.dispatch(getAllMenusAndPrivileges({ userName }));
 subScribeMenusAndPrivileges();
 } else {
 injectView(); // 未登录则渲染登录页面
 }
});
// 监听异步权限和菜单数据
const subScribeMenusAndPrivileges = () => {
 const unlistener = store.subscribe(() => {
 unlistener();
 const { auth: { privileges, menus, allMenus, account } } = store.getState();
 store.dispatch(setMenus(menus)); // 设置主应用的菜单,据此渲染主应用 lookcms 的菜单
 injectView(); // 挂载登录态的视图
 // 启动qiankun,并将菜单、权限、用户信息等传递,用于后续传递给子应用,拦截子应用的请求
 startQiankun(allMenus, privileges, account, store); 
 });
};
// 根据登录状态渲染页面
const injectView = () => {
 const { auth: { account: { login } } } = store.getState();
 if (login) {
 new App().$inject('#j-main');
 } else {
 new Auth().$inject('#j-main');
 window.history.pushState({}, '', `${$config.rootPath}/auth?redirect=${window.location.pathname}`);
 }
};
  1. 引入 qiankun,注册 liveadmin 和 increase 这两个子应用。

定义好子应用,按照 qiankun 官方的文档,确定 name、entry、container 和 activeRule 字段,其中 entry 配置注意区分环境,并接收上一步的 menus, privileges等数据,基本代码如下:

// 定义子应用集合
const subApps = [{ // liveadmin 旧工程
 name: 'music-live-admin', // 取子应用的 package.json 的 name 字段
 entrys: { // entry 区分环境
 dev: '//localhost:3001',
 // liveadmin这里定义 rootPath为 liveadminlegacy,便于将原有的 liveadmin 释放给主应用使用,以达到使用原始访问地址访问页面的目的。
 test: `//${window.location.host}/liveadminlegacy/`,
 online: `//${window.location.host}/liveadminlegacy/`,
 },
 pmsAppCode: 'live_legacy_backend', // 权限处理相关
 pmsCodePrefix: 'module_livelegacyadmin', // 权限处理相关
 defaultMenus: ['welcome', 'activity']
}, { // increase 新工程
 name: 'music-live-admin-react',
 entrys: {
 dev: '//localhost:4444',
 test: `//${window.location.host}/lookadmin/`,
 online: `//${window.location.host}/lookadmin/`,
 },
 pmsAppCode: 'look_backend',
 pmsCodePrefix: 'module_lookadmin',
 defaultMenus: []
}];
// 注册子应用
registerMicroApps(subApps.map(app => ({
 name: app.name,
 entry: app.entrys[$config.env], // 子应用的访问入口
 container: '#j-subapp', // 子应用在主应用的挂载点
 activeRule: ({ pathname }) => { // 定义加载当前子应用的路由匹配策略,此处是根据 pathname 和当前子应用的菜单 key 比较来做的判断
 const curAppMenus = allMenus.find(m => m.appCode === app.pmsAppCode).subMenus.map(({ name }) => name);
 const isInCurApp = !!app.defaultMenus.concat(curAppMenus).find(headKey => pathname.indexOf(`${$config.rootPath}/${headKey}`) > -1);
 return isInCurApp;
 },
 // 传递给子应用的数据:菜单、权限、账户,可以使得子应用不再请求相关数据,当然子应用需要做好判断
 props: { menus: allMenus.find(m => m.appCode === app.pmsAppCode).subMenus, privileges, account }
})));
// ...
start({ prefetch: false });
  1. 主应用菜单逻辑

我们基于已有的 menus 菜单数据,使用内部的 UI 组件完成了菜单的渲染,对每一个菜单绑定了点击事件,点击后通过 pushState 的方式,变更窗口的路径。比如点击 a-b 菜单,对应的路由便是 http://example.com/liveadmin/a/b,qiankun 会响应路由的变化,根据定义的 activeRule 匹配到对应的的子应用,接着子应用接管路由,加载子应用对应的页面资源。详细的实现过程可以参考 qiankun 源码,基本的思想是清洗子应用入口返回的 html 中的 <script> 标签 ,fetch 模块的 Javascript 资源,然后通过 eval 执行对应的 Javascript。

2.2 liveadmin 子应用

  1. 按照 qiankun 官方文档的做法,在子应用的入口文件中导出相应的生命周期钩子函数。
if (window.__POWERED_BY_QIANKUN__) { // 注入 Webpack publicPath, 使得主应用正确加载子应用的资源
 __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
if (!window.__POWERED_BY_QIANKUN__) { // 独立访问启动逻辑
 bootstrapApp({});
}
export const bootstrap = async () => { // 启动前钩子
 await Promise.resolve(1);
};
export const mount = async (props) => { // 集成访问启动逻辑,接手主应用传递的数据
 bootstrapApp(props);
};
export const unmount = async (props) => {  // 卸载子应用的钩子
 props.container.querySelector('#j-look').remove();
};
  1. 修改 Webpack 打包配置。
output: {
 path: DIST_PATH,
 publicPath: ROOTPATH,
 filename: '[name].js',
 chunkFilename: '[name].js',
 library: `${packageName}-[name]`,
 libraryTarget: 'umd', // 指定打包的 Javascript UMD 格式
 jsonpFunction: `webpackJsonp_${packageName}`,
},
  1. 处理集成访问时,隐藏子应用的头部和侧边栏元素。
const App = Regular.extend({
 template: window.__POWERED_BY_QIANKUN__
 ? `
 <div class="g-wrapper" r-view></div>
 `
 : `
 <div class="g-bd">
 <div class="g-hd mui-row">
 <AppHead menus={headMenus}
 moreMenus={moreMenus}
 selected={selectedHeadMenuKey}
 open={showSideMenu}
 on-select={actions.selectHeadMenu($event)}
 on-toggle={actions.toggleSideMenu()}
 on-logout={actions.logoutAuth}></AppHead>
 </div>
 <div class="g-main mui-row">
 <div class="g-sd mui-col-4" r-hide={!showSideMenu}>
 <AppSide menus={sideMenus} 
 selected={selectedSideMenuKey}
 show={showSideMenu}
 on-select={actions.selectSideMenu($event)}></AppSide>
 </div>
 <div class="g-cnt" r-class={cntClass}>
 <div class="g-wrapper" r-view></div>
 </div>
 </div> 
 </div> 
 `,
 name: 'App',
 // ...
})
  1. 处理集成访问时,屏蔽权限数据和登录信息的请求,改为接收主应用传递的权限和菜单数据,避免冗余的 HTTP 请求和数据设置。
if (props.container) { // 集成访问时,直接设置权限和菜单
 store.dispatch(setMenus(props.menus))
 store.dispatch({
 type: 'GET_PRIVILEGES_SUCCESS',
 payload: {
 privileges: props.privileges,
 menus: props.menus
 }
 });
} else { // 独立访问时,请求用户权限,菜单直接读取本地的配置
 MixInMenus(props.container);
 store.dispatch(getPrivileges({ userName: name }));
}
if (props.container) {  // 集成访问时,设置用户登录账户
 store.dispatch({
 type: 'LOGIN_STATUS_SUCCESS',
 payload: {
 user: props.account,
 loginType: 'OPENID'
 }
 });
} else { // 独立访问时,请求和设置用户登录信息
 store.dispatch(loginStatus());
}
  1. 处理集成访问时,路由 base 更改

因为集成访问时要统一 rootPath 为 liveadmin,所以集成访问时注册的路由要修改成主应用的 rootPath 以及新的挂载点。

const start = (container) => {
 router.start({
 root: config.base,
 html5: true,
 view: container ? container.querySelector('#j-look') : Regular.dom.find('#j-look')
 });
};

2.3 increase 子应用

同 liveadmin 子应用做的事类似。

  1. 导出相应的生命周期钩子。
if (window.__POWERED_BY_QIANKUN__) {
 __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
const CONTAINER = document.getElementById('container');
if (!window.__POWERED_BY_QIANKUN__) {
 const history = createBrowserHistory({ basename: Config.base });
 ReactDOM.render(
 <Provider store={store()}>
 <Symbol />
 <Router path="/" history={history}>
 {routeChildren()}
 </Router>
 </Provider>,
 CONTAINER
 );
}
export const bootstrap = async () => {
 await Promise.resolve(1);
};
export const mount = async (props) => {
 const history = createBrowserHistory({ basename: Config.qiankun.base });
 ReactDOM.render(
 <Provider store={store()}>
 <Symbol />
 <Router path='/' history={history}>
 {routeChildren(props)}
 </Router>
 </Provider>,
 props.container.querySelector('#container') || CONTAINER
 );
};
export const unmount = async (props) => {
 ReactDOM.unmountComponentAtNode(props.container.querySelector('#container') || CONTAINER);
};
  1. Webpack 打包配置。
output: {
 path: DIST_PATH,
 publicPath: ROOTPATH,
 filename: '[name].js',
 chunkFilename: '[name].js',
 library: `${packageName}-[name]`,
 libraryTarget: 'umd',
 jsonpFunction: `webpackJsonp_${packageName}`,
},
  1. 集成访问时,去掉头部和侧边栏。
if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-line
 return (
 <BaseLayout location={location} history={history} pms={pms}>
 <Fragment>
 {
 curMenuItem && curMenuItem.block
 ? blockPage
 : children
 }
 </Fragment>
 </BaseLayout>
 );
}
  1. 集成访问时,屏蔽权限和登录请求,接收主应用传递的权限和菜单数据。
useEffect(() => {
 if (login.status === 1) {
 history.push(redirectUrl);
 } else if (pms.account) { // 集成访问,直接设置数据
 dispatch('Login/success', pms.account);
 dispatch('Login/setPrivileges', pms.privileges);
 } else { // 独立访问,请求数据
 loginAction.getLoginStatus().subscribe({
 next: () => {
 history.push(redirectUrl);
 },
 error: (res) => {
 if (res.code === 301) {
 history.push('/login', {
 redirectUrl,
 host
 });
 }
 }
 });
 }
});
  1. 集成访问时,更改 react-router base。
export const mount = async (props) => {
 const history = createBrowserHistory({ basename: Config.qiankun.base });
 ReactDOM.render(
 <Provider store={store()}>
 <Symbol />
 <Router path='/' history={history}>
 {routeChildren(props)}
 </Router>
 </Provider>,
 props.container.querySelector('#container') || CONTAINER
 );
};

2.4 权限集成(可选步骤)

  1. 上文提到,一个前端应用关联一个 PMS 权限应用,那么如果通过微前端的方式组合了每个前端应用,而每个前端子应用如果还依然对应自己的 PMS 权限应用的权限,那么站在权限管理人员的角度而言,就需要关注多个 PMS 权限应用,进行分配权限、管理角色,操作起来都很麻烦,比如两个子应用的页面区分,两个子应用同一权限的角色管理等。因此,需要考虑将子应用对应的 PMS 权限应用也统一起来,这里仅描述我们的处理方式,仅供参考。
  2. 要尽量维持原有的权限管理方式(权限管理人员通过前端应用后门推送页面权限码到 PMS,然后到 PMS 进行页面权限分配),则微前端场景下,权限集成需要做的事情可以描述为:

    1. 各个子应用先推送本工程的菜单和权限码数据到到各自的 PMS 权限应用。
    2. 主应用加载各子应用的菜单和权限码数据,修改每个菜单和权限码的数据为主应用对应的 PMS 权限应用数据,然后统一推送到主应用对应的 PMS 权限应用,权限管理人员可以在主应用对应的 PMS 权限应用内进行权限的统一分配管理。
  3. 在我们的实践中,为了使权限管理人员依旧不感知这种拆分应用带来的变化,依旧使用原 liveadmin 应用对应的 appCode = live_backend PMS 权限应用进行权限分配,我们需要把 liveadmin 对应的 PMS 权限应用更改为 lookcms 主应用对应的 PMS 权限应用,而为 liveadmin 子应用新建一个 appCode = live_legacy_backend 的 PMS 权限应用,新的 increase 子应用则继续对应 appCode = look_backend 这个PMS 权限应用。以上两个子应用的菜单和权限码数据按照上一步描述的第 2 点各自上报给对应的 PMS 权限应用。最后 lookcms 主应用同时获取 appCode = live_legacy_backend 和 appCode = look_backend 这两个 PMS 权限应用的前端子应用菜单和权限码数据,修改为 appCode = live_backend 的 PMS 权限应用数据,推送到 PMS,整体的流程如下图所示,左边是原有的系统设计,右边是改造的系统设计。

2.5 部署

  1. liveadmin 和 increase 各自使用云音乐的前端静态部署系统进行独立部署,主应用 lookcms 也是独立部署。
  2. 处理好主应用访问子应用资源跨域的问题。在我们的实践过程中,由于都部署在同一个域下,资源打包遵循了同域规则。

2.6 小结

自此,我们已经完成了基于 qiankun LOOK 直播运营后台的微前端的实现,主要是新建了主工程,划分了主应用的职责,同时修改了子工程,使得子应用可以被集成到主应用被访问,也可以保持原有独立访问功能。整体的流程,可以用下图描述:

三.依赖共享

qiankun 官方并没有推荐具体的依赖共享解决方案,我们对此也进行了一些探索,结论可以总结为:对于 Regularjs,React 等 Javascript 公共库的依赖的可以通过 Webpack 的 externals 和 qiankun 加载子应用生命周期函数以及 import-html-entry 插件来解决,而对于组件等需要代码共享的场景,则可以使用 Webapck 5 的 module federation plugin 来解决。具体方案如下:
3.1. 我们整理出的公共依赖分为两类
3.1.1. 一类是基础库,比如 Regularjs,Regular-state,MUI,React,React Router 等期望在整个访问周期中不要重复加载的资源。
3.1.2. 另一类是公共组件,比如 React 组件需要在各子应用之间互相共享,不需要进行工程间的代码拷贝。
3.2. 对于以上两类依赖,我们做了一些本地的实践,因为还没有迫切的业务需求以及 Webpack 5 暂为发布稳定版(截至本文发布时,Webpack 5 已经发布了 release 版本,后续看具体的业务需求是否上线此部分 feature ),因此还没有在生产环境验证,但在这里可以分享下处理方式和结果。
3.2.1. 对于第一类公共依赖,我们实现共享的期望的是:在集成访问时,主应用可以动态加载子应用强依赖的库,子应用自身不再加载,独立访问时,子应用本身又可以自主加载自身需要的依赖。这里就要处理好两个问题:a. 主应用怎么搜集和动态加载子应用的依赖 b. 子应用怎么做到集成和独立访问时对资源加载的不同表现。
3.2.1.1. 第一个问题,我们需要维护一个公共依赖的定义,即在主应用中定义每个子应用所依赖的公共资源,在 qiankun 的全局微应用生命周期钩子 beforeLoad 中通过插入 <script> 标签的方式,加载当前子应用所需的 Javascript 资源,参考代码如下。

// 定义子应用的公共依赖
const dependencies = {
 live_backend: ['regular', 'restate'],
 look_backend: ['react', 'react-dom']
};
// 返回依赖名称
const getDependencies = appName => dependencies[appName];
// 构建script标签
const loadScript = (url) => {
 const script = document.createElement('script');
 script.type = 'text/javascript';
 script.src = url;
 script.setAttribute('ignore', 'true'); // 避免重复加载
 script.onerror = () => {
 Message.error(`加载失败${url},请刷新重试`);
 };
 document.head.appendChild(script);
};
// 加载某个子应用前加载当前子应用的所需资源
beforeLoad: [
 (app) => {
 console.log('[LifeCycle] before load %c%s', 'color: green;', app.name);
 getDependencies(app.name).forEach((dependency) => {
 loadScript(`${window.location.origin}/${$config.rootPath}${dependency}.js`);
 });
 }
],

这里还要注意通过 Webpack 来生产好相应的依赖资源,我们使用的是 copy-webpack-plugin 插件将 node_modules 下的 release 资源转换成包成可以通过独立 URL 访问的资源。

// 开发
plugins: [
 new webpack.DefinePlugin({
 'process.env': {
 NODE_ENV: JSON.stringify('development')
 }
 }),
 new webpack.NoEmitOnErrorsPlugin(),
 new CopyWebpackPlugin({
 patterns: [
 { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.js'), to: '../s/regular.js' },
 { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' },
 { from: path.join(__dirname, '../node_modules/react/umd/react.development.js'), to: '../s/react.js' },
 { from: path.join(__dirname, '../node_modules/react-dom/umd/react-dom.development.js'), to: '../s/react-dom.js' }
 ]
 })
],
// 生产
new CopyWebpackPlugin({
 patterns: [
 { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.min.js'), to: '../s/regular.js' },
 { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' },
 { from: path.join(__dirname, '../node_modules/react/umd/react.production.js'), to: '../s/react.js' },
 { from: path.join(__dirname, '../node_modules/react-dom/umd/react-dom.production.js'), to: '../s/react-dom.js' }
 ]
})

3.2.1.2. 关于子应用集成和独立访问时,对公共依赖的二次加载问题,我们采用的方法是,首先子应用将主应用已经定义的公共依赖通过 copy-webpack-plugin 和 html-webpack-externals-plugin 这两个插件使用 external 的方式独立出来,不打包到 Webpack bundle 中,同时通过插件的配置,给 <script> 标签加上 ignore 属性,那么在 qiankun 加载这个子应用时使用,qiankun 依赖的 import-html-entry 插件分析到 <script> 标签时,会忽略加载有 ignore 属性的 <script> 标签,而独立访问时子应用本身可以正常加载这个 Javascript 资源。

plugins: [
 new CopyWebpackPlugin({
 patterns: [
 { from: path.join(__dirname, '../node_modules/regularjs/dist/regular.js'), to: '../s/regular.js' },
 { from: path.join(__dirname, '../node_modules/regular-state/restate.pack.js'), to: '../s/restate.js' },
 ]
 }),
 new HtmlWebpackExternalsPlugin({
 externals: [{
 module: 'remoteEntry',
 entry: 'http://localhost:3000/remoteEntry.js'
 }, {
 module: 'regularjs',
 entry: {
 path: 'http://localhost:3001/regular.js',
 attributes: { ignore: 'true' }
 },
 global: 'Regular'
 }, {
 module: 'regular-state',
 entry: {
 path: 'http://localhost:3001/restate.js',
 attributes: { ignore: 'true' }
 },
 global: 'restate'
 }],
 })
],

3.2.2. 针对第二类共享代码的场景,我们调研了 Webpack 5 的 module federation plugin, 通过应用之间引用对方导入导出的 Webpack 编译公共资源信息,来异步加载公共代码,从而实现代码共享。
3.2.2.1. 首先,我们实践所定义的场景是:lookcms 主应用同时提供基于 Regularjs 的 RButton 组件和基于 React 的 TButton 组件分别共享给 liveadmin 子应用和 increase 子应用。
3.2.2.2. 对于 lookcms 主应用,我们定义 Webpack5 module federation plugin 如下:

plugins: [
 // new BundleAnalyzerPlugin(),
 new ModuleFederationPlugin({
 name: 'lookcms',
 library: { type: 'var', name: 'lookcms' },
 filename: 'remoteEntry.js',
 exposes: {
 TButton: path.join(__dirname, '../client/exports/rgbtn.js'),
 RButton: path.join(__dirname, '../client/exports/rcbtn.js'),
 },
 shared: ['react', 'regularjs']
 }),
],

定义的共享代码组件如下图所示:

3.2.2.3. 对于 liveadmin 子应用,我们定义 Webpack5 module federation plugin 如下:

plugins: [
 new BundleAnalyzerPlugin(),
 new ModuleFederationPlugin({
 name: 'liveadmin_remote',
 library: { type: 'var', name: 'liveadmin_remote' },
 remotes: {
 lookcms: 'lookcms',
 },
 shared: ['regularjs']
 }),
],

使用方式上,子应用首先要在 html 中插入源为 http://localhost:3000/remoteEntry.js 的主应用共享资源的入口,可以通过 html-webpack-externals-plugin 插入,见上文子应用的公共依赖 external 处理。
对于外部共享资源的加载,子应用都是通过 Webpack 的 import 方法异步加载而来,然后插入到虚拟 DOM 中,我们期望参考 Webapck 给出的 React 方案做 Regularjs 的实现,很遗憾的是 Regularjs 并没有相应的基础功能帮我们实现 Lazy 和 Suspense。
通过一番调研,我们选择基于 Regularjs 提供的 r-component API 来条件渲染异步加载的组件。
基本的思想是定义一个 Regularjs 组件,这个 Regularjs 组件在初始化阶段从 props 中获取要加载的异步组件 name ,在构建阶段通过 Webpack import 方法加载 lookcms 共享的组件 name,并按照 props 中定义的 name 添加到 RSuspense 组件中,同时修改 RSuspense 组件 r-component 的展示逻辑,展示 name 绑定的组件。
由于 Regularjs 的语法书写受限,我们不便将上述 RSuspense 组件逻辑抽象出来,因此采用了 Babel 转换的方式,通过开发人员定义一个组件的加载模式语句,使用 Babel AST 转换为 RSuspense 组件。最后在 Regularjs 的模版中使用这个 RSuspense 组件即可。

// 支持定义一个 fallback
const Loading = Regular.extend({
 template: '<div>Loading...{content}</div>',
 name: 'Loading'
});
// 写成一个 lazy 加载的模式语句
const TButton = Regular.lazy(() => import('lookcms/TButton'), Loading);
// 模版中使用 Babel AST 转换好的 RSuspense 组件
`<RSuspense origin='lookcms/TButton' fallback='Loading' />`

通过 Babel AST 做的语法转换如下图所示:

实际运行效果如下图所示:

3.2.2.4. 对于 increase 子应用,我们定义 Webpack 5 module federation plugin 如下:

plugins: [
 new ModuleFederationPlugin({
 name: 'lookadmin_remote',
 library: { type: 'var', name: 'lookadmin_remote' },
 remotes: {
 lookcms: 'lookcms',
 },
 shared: ['react']
 }),
],

使用方式上,参考 Webpack 5 的官方文档即可,代码如下:

const RemoteButton = React.lazy(() => import('lookcms/RButton'));
const Home = () => (
 <div className="m-home">
 欢迎
 <React.Suspense fallback="Loading Button">
 <RemoteButton />
 </React.Suspense>
 </div>
);

实际运行效果如下图所示:

  1. 总结

四.注意事项

  1. 跨域资源
    如果你的应用内通过其他方式实现了跨域资源的加载,请注意 qiankun 是通过 fetch 的方式加载所有子应用资源的,因此跨域的资源需要通过 CORS 实现跨域访问。
  2. 子应用的 html 标签
    可能你的某个子应用的 html 标签上设置了某些属性或者附带了某些功能,要注意 qiankun 实际处理中剥离掉了子应用的 html 标签,因此如果由设置 rem 的需求,请注意使用其他方式适配。

五.未来

  1. 自动化
    子应用的接入通过平台的方式接入,当然这需要子应用遵守的规范行程。
  2. 依赖共享
    Webpack 5 已经发布了其正式版本,因此对于 module federation plugin 的使用可以提上工作日程。

六.总结

LOOK 直播运营后台基于实际的业务场景,使用 qiankun 进行了微前端方式的工程拆分,目前在生产环境平稳运行了近 4 个月,在实践的过程中,确实在需求确立和接入 qiankun 的实现以及部署应用几个阶段碰到了一些难点,比如开始的需求确立,我们对要实现的主菜单功能有过斟酌,在接入 qiankun 的过程中经常碰到报错,在部署的过程中也遇到内部部署系统的抉择和阻碍,好在同事们给力,项目能顺利的上线和运行。

参考资料

本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!
查看原文

赞 1 收藏 0 评论 0

savokiss 赞了文章 · 10月13日

Go并发编程之传统同步—(3)原子操作

前言

之前文章中介绍的互斥锁虽然能够保证同串行化,但是却保证不了执行过程中的中断。
要么成功、要么失败,没有中断的情况,我们叫它叫原子性,这种由硬件 CPU 提供支持的特性,是非常可靠的。

百度百科上关于原子操作的介绍。

原子操作

由 sync/atomic 包提供操作支持。

加法(add)

实现累加

func TestDemo1(t *testing.T) {
    var counter int64 = 0

    for i := 0; i < 100; i++ {
        go func() {
            atomic.AddInt64(&counter, 1)
        }()
    }

    time.Sleep(2 * time.Second)
    log.Println("counter:", atomic.LoadInt64(&counter))
}

结果

=== RUN   TestDemo1
2020/10/11 00:24:56 counter: 100
--- PASS: TestDemo1 (2.00s)
PASS

减法(add)

对于做减法,是没有直接提供的方法的,而 Add(-1)这种是不能对 uint 类型使用的,可以通过补码的方式实现

func TestDemo2(t *testing.T) {
    var counter uint64 = 100

    for i := 0; i < 100; i++ {
        go func() {
            atomic.AddUint64(&counter, ^uint64(-(-1)-1))
        }()
    }

    time.Sleep(2 * time.Second)
    log.Println("counter:", atomic.LoadUint64(&counter))
}

结果

=== RUN   TestDemo2
2020/10/11 00:32:05 counter: 0
--- PASS: TestDemo2 (2.00s)
PASS

比较并交换(compare and swap,简称 CAS)

并发编程中,在没有使用互斥锁的前提下,对共享数据先取出做判断,再根据判断的结果做后续操作,必然是会出问题的,使用 CAS 可以避免这种问题。

func TestDemo3(t *testing.T) {
    var first int64 = 0

    for i := 1; i <= 10000; i++ {
        go func(i int) {
            if atomic.CompareAndSwapInt64(&first, 0, int64(i)) {
                log.Println("抢先运行的是 goroutine", i)
            }
        }(i)
    }

    time.Sleep(2 * time.Second)
    log.Println("num:", atomic.LoadInt64(&first))
}

结果

=== RUN   TestDemo3
2020/10/11 00:42:10 抢先运行的是 goroutine 3
2020/10/11 00:42:12 num: 3
--- PASS: TestDemo3 (2.01s)
PASS

加载(load)

加载操作在进行时只会有一个,不会有其它的读写操作同时进行。

func TestDemo4(t *testing.T) {
    var counter int64 = 0

    for i := 0; i < 100; i++ {
        go func() {
            atomic.AddInt64(&counter, 1)
            log.Println("counter:", atomic.LoadInt64(&counter))
        }()
    }

    time.Sleep(2 * time.Second)
}

存储(store)

存储操作在进行时只会有一个,不会有其它的读写操作同时进行。

func TestDemo5(t *testing.T) {
    var counter int64 = 0

    for i := 0; i < 10; i++ {
        go func(i int) {
            atomic.StoreInt64(&counter, int64(i))
            log.Println("counter:", atomic.LoadInt64(&counter))
        }(i)
    }

    time.Sleep(2 * time.Second)
}

交换(swap)

swap 方法返回被替换之前的旧值。

func TestDemo6(t *testing.T) {
    var counter int64 = 0

    for i := 0; i < 10; i++ {
        go func(i int) {
            log.Println("counter old:", atomic.SwapInt64(&counter, int64(i)))
        }(i)
    }

    time.Sleep(2 * time.Second)
}

结果

=== RUN   TestDemo6
2020/10/11 00:43:36 counter old: 0
2020/10/11 00:43:36 counter old: 9
2020/10/11 00:43:36 counter old: 5
2020/10/11 00:43:36 counter old: 1
2020/10/11 00:43:36 counter old: 2
2020/10/11 00:43:36 counter old: 3
2020/10/11 00:43:36 counter old: 6
2020/10/11 00:43:36 counter old: 4
2020/10/11 00:43:36 counter old: 7
2020/10/11 00:43:36 counter old: 0
--- PASS: TestDemo6 (2.00s)
PASS

原子值(value)

value是一个结构体,内部值定义为 interface{},所以它是可以接受任何类型的值。

第一次赋值的时候,原子值的类型就确认了,后面不能赋值其它类型的值。

func TestDemo7(t *testing.T) {
    var value atomic.Value
    var counter uint64 = 1

    value.Store(counter)
    log.Println("counter:", value.Load())

    value.Store(uint64(10))
    log.Println("counter:", value.Load())

    value.Store(100) // 引发 panic
    log.Println("counter:", value.Load())

    time.Sleep(2 * time.Second)
}

结果

=== RUN   TestDemo7
2020/10/11 10:14:58 counter: 0
2020/10/11 10:14:58 counter: 10
--- FAIL: TestDemo7 (0.00s)
panic: sync/atomic: store of inconsistently typed value into Value [recovered]
    panic: sync/atomic: store of inconsistently typed value into Value
                ...
Process finished with exit code 1

扩展

无锁编程

此处暂时先介绍一下,后面有机会出文章再一起学习进步。

放弃互斥锁,采用原子操作,常见方法有以下几种:

针对计数器

可以使用例如上面介绍的 Add 方法。

单生产、消费者

单生产者、单消费者可以做到免锁访问环形缓冲区(Ring Buffer)。
比如,Linux kernel 中的 kfifo 的实现。

RCU(Read Copy Update)

新旧副本切换机制,对于旧副本可以采用延迟释放的做法。

CAS(Compare And Swap)

如无锁栈,无锁队列等待

总结

  1. 原子操作性能是高于互斥锁的,但带来的复杂性也会提高,真正用好并不容易。
  2. 互斥锁、条件变量,方法内部的实现也都用到了原子操作,特别是CAS。

文章示例代码

Sown专栏地址:https://segmentfault.com/blog/sown

查看原文

赞 1 收藏 0 评论 0

savokiss 赞了文章 · 10月10日

Gunicorn timeout(下)

Gunicorn 运行命令增加 -k gevent 配置

Gunicornpre-fork 模型,worker 默认是 sync 改为 gevent

查看原文

赞 2 收藏 1 评论 0

savokiss 赞了文章 · 10月10日

Scrapy入门教程

安装Scrapy

pip install Scrapy

创建项目

scrapy startproject tutorial

创建爬虫

tutorial/spiders 目录下创建 quotes_spider.py 文件,代码如下:

import scrapy


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        urls = [
            'https://segmentfault.com/blog/sown',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        for quote in response.css('section.stream-list__item'):
            print(quote.css('h2.title a::text').extract_first())
            print(quote.css('h2.title a::attr(href)').extract_first())

启动前配置

settings.py 中添加:

USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36'
ROBOTSTXT_OBEY = False

启动项目

scrapy crawl quotes

界面输出DEBUG、INFO的提示信息,还有抓取的文章标题和链接。一个最简单的初级爬虫,基本流程就已经跑通了。

抓取二级页面

quotes_spider.py:

import urllib

import scrapy


def parse_article(response):
    article = response.css('article.article').extract_first()
    print(article)


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        urls = [
            'https://segmentfault.com/blog/sown',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        for quote in response.css('section.stream-list__item'):
            print(quote.css('h2.title a::text').extract_first())
            article = urllib.parse.urljoin(response.url, quote.css('h2.title a::attr(href)').extract_first())
            yield scrapy.Request(
                url=article,
                callback=parse_article
            )

保存数据到MySQL

items.py

# -*- coding: utf-8 -*-
import scrapy


class ArticleItem(scrapy.Item):
    title = scrapy.Field()
    content = scrapy.Field()
    pass

pipelines.py

# -*- coding: utf-8 -*-
import pymysql as pymysql
from pymysql.cursors import DictCursor


class TutorialPipeline(object):
    def process_item(self, item, spider):
        return item


class MySQLPipeline(object):
    def __init__(self):
        self.connect = pymysql.connect(
            host='127.0.0.1',
            port=3306,
            db='spider',
            user='root',
            passwd='root',
            charset='utf8',
            use_unicode=True)
        self.cursor = self.connect.cursor(DictCursor)

    def process_item(self, item, spider):
        self.cursor.execute(
            """insert into article(
            title, 
            content
            ) value (%s, %s)""",
            (
                item['title'],
                item['content']
            )
        )
        self.connect.commit()
        return item

quotes_spider.py:

import urllib

import scrapy
from ..items import ArticleItem


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def start_requests(self):
        urls = [
            'https://segmentfault.com/blog/sown',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        for quote in response.css('section.stream-list__item'):
            title = quote.css('h2.title a::text').extract_first()
            article = urllib.parse.urljoin(response.url, quote.css('h2.title a::attr(href)').extract_first())
            yield scrapy.Request(
                url=article,
                callback=self.parse_article,
                meta={'title': title}
            )
            
    def parse_article(self, response):
        title = response.meta['title']
        content = response.css('article.article').extract_first()
        item = ArticleItem()
        item['title'] = title
        item['content'] = content
        yield item

settings.py:

ITEM_PIPELINES = {
   'tutorial.pipelines.MySQLPipeline': 300
}

通过启动命令,传递 start_url 参数

quotes_spider.py:

import urllib

import scrapy
from ..items import ArticleItem


class QuotesSpider(scrapy.Spider):
    name = "quotes"

    def __init__(self, start_url=None, *args, **kwargs):
        super(QuotesSpider, self).__init__(*args, **kwargs)
        self.start_url = start_url

    def start_requests(self):
        urls = [
            'https://segmentfault.com',
        ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        yield scrapy.Request(
            self.start_url,
            callback=self.parse_list,
            meta={}
        )

    def parse_list(self, response):
        for quote in response.css('section.stream-list__item'):
            title = quote.css('h2.title a::text').extract_first()
            article = urllib.parse.urljoin(response.url, quote.css('h2.title a::attr(href)').extract_first())
            yield scrapy.Request(
                url=article,
                callback=self.parse_article,
                meta={'title': title}
            )

    def parse_article(self, response):
        title = response.meta['title']
        content = response.css('article.article').extract_first()
        item = ArticleItem()
        item['title'] = title
        item['content'] = content
        yield item

执行:
scrapy crawl quotes -a start_url=https://segmentfault.com/blog/sown

可能会遇到的一些问题

  1. 抓取的内容中存在 <br> ,导致本应返回 string 变成 list
    正常来说使用 text=response.css('[id=content]::text').extract() 本应返回全部的文本内容,但是因为内容存在 <br>,所以它会返回 <br> 分割后的 list
    这个时候,就要根据实际情况,是合并 list,还是更改匹配规则的策略了。
  2. flask 中调用 scrapy,错误提示 “ValueError: signal only works in main thread”
    换成下面的调用方式

     subprocess.run(['scrapy', 'crawl', 'nmzsks', "-a", "year=" + year, "-a", "start_url=" + start_url], shell=True)
  3. No module named ArticleItem.items
    引用时加上 ..

     from ..items import ArticleItem
  4. 中文抓取后乱码
    可能抓取的页面不是 utf-8 编码,scrapy 水土不服,用下面的方式转换一下

     content.encode('latin1').decode('gbk')
查看原文

赞 2 收藏 1 评论 0

savokiss 赞了文章 · 10月10日

Go并发编程之传统同步—(2)条件变量

前言

回顾上篇文章《Go并发编程之传统同步—(1)互斥锁》其中说到,同步最终是为了达到以下两种目的:

  • 维持共享数据一致性,并发安全
  • 控制流程管理,更好的协同工作

示例程序通过使用互斥锁,达到了数据一致性目的,那么流程管理应该怎么做呢?

传统同步

条件变量

上篇文章的示例程序,仅仅实现了累加功能,但在现实的工作场景中,需求往往不可能这么简单,现在扩展一下这个程序,给它加上累减的功能。

加上了累减的示例程序,可以抽象的理解为一个固定容量的“储水池”,可以注水、排水。

仅用互斥锁

当水注满以后,停止注水,开始排水,当水排空以后,开始注水,反反复复...

func TestDemo1(t *testing.T) {
    var mut sync.Mutex
    maxSize := 10
    counter := 0

    // 排水口
    go func() {
        for {
            mut.Lock()
            if counter == maxSize {
                for i := 0; i < maxSize; i++ {
                    counter--
                    log.Printf("OUTPUT counter = %d", counter)
                }
            }
            mut.Unlock()
            time.Sleep(1 * time.Second)
        }
    }()

    // 注水口
    for {
        mut.Lock()
        if counter == 0 {
            for i := 0; i < maxSize; i++ {
                counter++
                log.Printf(" INPUT counter = %d", counter)
            }
        }
        mut.Unlock()
        time.Sleep(1 * time.Second)
    }
}

结果

=== RUN   TestDemo1
                ···
2020/10/06 13:52:50  INPUT counter = 8
2020/10/06 13:52:50  INPUT counter = 9
2020/10/06 13:52:50  INPUT counter = 10
2020/10/06 13:52:50 OUTPUT counter = 9
2020/10/06 13:52:50 OUTPUT counter = 8
2020/10/06 13:52:50 OUTPUT counter = 7
                ···

看着没有什么问题,一切正常,但就是这样工作的策略效率太低。

优化互斥锁

优化策略,不用等注满水再排水,也不用放空之后,再注水,注水口和排水口一起工作。

func TestDemo2(t *testing.T) {
    var mut sync.Mutex
    maxSize := 10
    counter := 0

    // 排水口
    go func() {
        for {
            mut.Lock()
            if counter != 0 {
                counter--
            }
            log.Printf("OUTPUT counter = %d", counter)
            mut.Unlock()
            time.Sleep(5 * time.Second) // 为了演示效果,睡眠5秒
        }
    }()

    // 注水口
    for {
        mut.Lock()
        if counter != maxSize {
            counter++
        }
        log.Printf(" INPUT counter = %d", counter)
        mut.Unlock()
        time.Sleep(1 * time.Second) // 为了演示效果,睡眠1秒
    }
}

结果

=== RUN   TestDemo2
                ···
2020/10/06 14:11:46  INPUT counter = 7
2020/10/06 14:11:47  INPUT counter = 8
2020/10/06 14:11:48 OUTPUT counter = 7
2020/10/06 14:11:48  INPUT counter = 8
2020/10/06 14:11:49  INPUT counter = 9
2020/10/06 14:11:50  INPUT counter = 10
2020/10/06 14:11:51  INPUT counter = 10
2020/10/06 14:11:52  INPUT counter = 10
2020/10/06 14:11:53 OUTPUT counter = 9
2020/10/06 14:11:53  INPUT counter = 10
2020/10/06 14:11:54  INPUT counter = 10
2020/10/06 14:11:55  INPUT counter = 10
2020/10/06 14:11:56  INPUT counter = 10
2020/10/06 14:11:57  INPUT counter = 10
2020/10/06 14:11:58 OUTPUT counter = 9
2020/10/06 14:11:58  INPUT counter = 10
2020/10/06 14:11:59  INPUT counter = 10
                ···

通过日志输出,可以看到程序达到了需求,运作正常。

但是,通过日志输出发现,当排水口效率低下的时候,注水口一直在轮询,这里频繁的上锁操作造成的开销很是浪费。

条件变量:单发通知

那有没有什么好的办法,省去不必要的轮询?如果注水口和排水口能互相“通知”就好了!这个功能,条件变量可以做到。

条件变量总是与互斥锁组合使用,除了可以使用 Lock、Unlock,还有如下三个方法:

  • Wait 等待通知
  • Signal 单发通知
  • Broadcast 广播通知
func TestDemo3(t *testing.T) {
    cond := sync.NewCond(new(sync.Mutex)) // 初始化条件变量
    maxSize := 10
    counter := 0

    // 排水口
    go func() {
        for {
            cond.L.Lock() // 上锁
            if counter == 0 { // 没水了
                cond.Wait() // 啥时候来水?等通知!
            }
            counter--
            log.Printf("OUTPUT counter = %d", counter)
            cond.Signal() // 单发通知:已排水
            cond.L.Unlock() // 解锁
            time.Sleep(5 * time.Second) // 为了演示效果,睡眠5秒
        }
    }()

    // 注水口
    for {
        cond.L.Lock() // 上锁
        if counter == maxSize { // 水满了
            cond.Wait() // 啥时候排水?等待通知!
        }
        counter++
        log.Printf(" INPUT counter = %d", counter)
        cond.Signal() // 单发通知:已来水
        cond.L.Unlock() // 解锁
        time.Sleep(1 * time.Second) // 为了演示效果,睡眠1秒
    }
}

结果

=== RUN   TestDemo3
                ···
2020/10/06 14:51:22  INPUT counter = 7
2020/10/06 14:51:23  INPUT counter = 8
2020/10/06 14:51:24 OUTPUT counter = 7
2020/10/06 14:51:24  INPUT counter = 8
2020/10/06 14:51:25  INPUT counter = 9
2020/10/06 14:51:26  INPUT counter = 10
2020/10/06 14:51:29 OUTPUT counter = 9
2020/10/06 14:51:29  INPUT counter = 10
2020/10/06 14:51:34 OUTPUT counter = 9
2020/10/06 14:51:34  INPUT counter = 10
                ···

通过日志输出,可以看出来,注水口没有一直轮询了,而是等到排水口发通知后,再进行注水,注水口一直再等排水口。那么新的问题又来了,如何提高排水口的效率呢?

条件变量:广播通知

多制造出一个排水口,提高排水效率。

那就不能继续使用单发通知了(Signal),因为单发通知只会通知到一个等待(Wait),针对多等待的这种情况,就需要使用广播通知(Broadcast)。

func TestDemo4(t *testing.T) {
    cond := sync.NewCond(new(sync.Mutex)) // 初始化条件变量
    maxSize := 10
    counter := 0

    // 排水口 1
    go func() {
        for {
            cond.L.Lock() // 上锁
            if counter == 0 { // 没水了
            //for counter == 0 { // 没水了
                cond.Wait() // 啥时候来水?等通知!
            }
            counter--
            log.Printf("OUTPUT A counter = %d", counter)
            cond.Broadcast() // 单发通知:已排水
            cond.L.Unlock() // 解锁
            //time.Sleep(2 * time.Second) // 为了演示效果,睡眠5秒
        }
    }()

    // 排水口 2
    go func() {
        for {
            cond.L.Lock() // 上锁
            if counter == 0 { // 没水了
            //for counter == 0 { // 没水了
                cond.Wait() // 啥时候来水?等通知!
            }
            counter--
            log.Printf("OUTPUT B counter = %d", counter)
            cond.Broadcast() // 单发通知:已排水
            cond.L.Unlock() // 解锁
            //time.Sleep(2 * time.Second) // 为了演示效果,睡眠5秒
        }
    }()

    // 注水口
    for {
        cond.L.Lock() // 上锁
        if counter == maxSize { // 水满了
        //for counter == maxSize { // 水满了
            cond.Wait() // 啥时候排水?等待通知!
        }
        counter++
        log.Printf(" INPUT   counter = %d", counter)
        cond.Broadcast() // 单发通知:已来水
        cond.L.Unlock() // 解锁
        //time.Sleep(1 * time.Second) // 为了演示效果,睡眠1秒
    }
}

结果

=== RUN   TestDemo4
                ···
2020/10/07 20:57:30 OUTPUT B counter = 2
2020/10/07 20:57:30 OUTPUT B counter = 1
2020/10/07 20:57:30 OUTPUT B counter = 0
2020/10/07 20:57:30 OUTPUT A counter = -1
2020/10/07 20:57:30 OUTPUT A counter = -2
2020/10/07 20:57:30 OUTPUT A counter = -3
2020/10/07 20:57:30 OUTPUT A counter = -4
                ···
2020/10/07 20:57:31 OUTPUT B counter = -7605
2020/10/07 20:57:31  INPUT   counter = -7604
2020/10/07 20:57:31 OUTPUT A counter = -7605
2020/10/07 20:57:31 OUTPUT A counter = -7606
                ···

通过日志输出可以看到,刚开始的时候还很正常,到后面的时候就变成负值了,一直在负增长,What?

《Go并发编程之传统同步—(1)互斥锁》文章中,程序因为没有加上互斥锁,出现过 counter 值异常的情况。

但这次程序这次加了互斥锁,按理说形成了一个临界区应该是没有问题了,所以问题应该不是出在临界区上,难道问题出在 Wait 上?

通过IDE追踪一下Wait的源码

func (c *Cond) Wait() {
    // 检查 c 是否是被复制的,如果是就 panic
    c.checker.check()
    // 将当前 goroutine 加入等待队列
    t := runtime_notifyListAdd(&c.notify)
    c.L.Unlock()
    // 等待当前 goroutine 被唤醒
    runtime_notifyListWait(&c.notify, t)
    c.L.Lock()
}

原来 Wait 内部的执行流程是,先执行了解锁,然后进入等待状态,接到通知之后,再执行加锁操作。

那按照这个代码逻辑结合输出日志,走一程序遍流程,看看能不能复现出 counter 为负值的情况:

  1. 注水口将 counter 累加到 10 之后,发送广播通知(Broadcast)。
  2. goroutine A 在“第1步”之前的时候进入了等待通知(Wait),现在接收到了广播通知(Broadcast),从 runtime_notifyListWait() 返回,并且成功执行了加锁(Lock)操作。
  3. goroutine B 在“第1步”之前的时候进入了等待通知(Wait),现在接收到了广播通知(Broadcast),从 runtime_notifyListWait() 返回,在执行加锁(Lock)操作的时候,发现 goroutine A 先抢占了临界区,所以一直阻塞在 c.L.Lock()。
  4. goroutine A 虽然完成任务后会释放锁,但是每次也成功将锁抢占,所以就这样 一直将 counter 减到了 0,然后发送广播通知(Broadcast)、解锁(Unlock)。
  5. goroutine B 在 goroutine A 解锁后,成功获得锁并从 Lock 方法中返回,接下来跳出 Wait 方法、跳出 if 判断,执行 counter--(0--),这时候 counter 的值是 -1

图示

image

问题就出现在第五步,只要 goroutine B 加锁成功的时候,再判断一下 counter 是否为 0 就好了。

所以将 if counter == 0 改成 for counter == 0,这样上面的“第五步”就变成了

5.goroutine B 在 goroutine A 解锁后,成功加锁(Lock)并从阻塞总返回,接下来跳出 Wait 方法、再次进入 for 循环,判断 counter == 0 结果为真,再次进入等待(Wait)。

代码做出相应的修改后,再执行看结果,没有问题了。

延伸

发送通知

等待通知(Wait)肯定是要在临界区里面的,那发送通知(Signal、Broadcast)在哪里更好呢?

Luck()
Wait()
Broadcast()// Signal()
Unlock()

// 或者

Luck()
Wait()
Unlock()
Broadcast()// Signal()

// 两种写法都不会报错 

在 go 的发送通知方法(Broadcast、Signal)上有这么一段话:

// It is allowed but not required for the caller to hold c.L
// during the call.

在我以往的 C 多线程开发的时候,发送通知总是在锁中的:

pthread_mutex_lock(&thread->mutex);
//              ...
pthread_cond_signal(&thread->cond);
pthread_mutex_unlock(&thread->mutex);

man 手册中有写到:

The pthread_cond_broadcast() or pthread_cond_signal() functions may be called by a thread whether or not it currently owns the mutex that threads calling pthread_cond_wait() or pthread_cond_timedwait() have associated with the condition variable during their waits; however, if predictable scheduling behavior is required, then that mutex shall be locked by the thread calling pthread_cond_broadcast() or pthread_cond_signal().

个人对此并没有什么见解,就不乱下定论了,有想法的小伙伴可以在文章下面留言,一起讨论。

等待通知

消息通知是有即时性的,如果没有 goroutine 在等待通知,那么这次通知直接被丢弃。

kubernetes

https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/client-go/tools/cache/fifo.go

总结

  1. Wait() 内会执行解锁、等待、加锁。
  2. Wait() 必须在 for 循环里面。
  3. Wait() 方法会把当前的 goroutine 添加到通知队列的队尾。
  4. 单发通知,唤醒通知队列第一个排队的 goroutine。
  5. 广播通知,唤醒通知队列里面全部的 goroutine。
  6. 程序示例只是为了演示效果,实际的开发中,生产者和消费者应该是异步消费,不应该使用同一个互斥锁。

文章示例代码

Sown专栏地址:https://segmentfault.com/blog/sown

查看原文

赞 8 收藏 3 评论 12

savokiss 赞了文章 · 10月4日

Go并发编程之传统同步—(1)互斥锁

前言

先回顾一下,在 C 或者其它编程语言的并发编程中,主要存在两种通信(IPC):

  • 进程间通信:管道、消息队列、信号等
  • 线程间通信:互斥锁、条件变量等

利用以上通信手段采取的同步措施,最终是为了达到以下两种目的:

  • 维持共享数据一致性,并发安全
  • 控制流程管理,更好的协同工作

Go语言中除了保留了传统的同步支持,还提供了特有的 CSP 并发编程模型。

传统同步

互斥锁

接下来通过一个“做累加”的示例程序,展示竞争状态(race condition)。

不加锁

开启 5000 个 goroutine,让每个 goroutine 给 counter 加 1,最终在所有 goroutine 都完成任务时 counter 的值应该为 5000,先试下不加锁的示例程序表现如何

func TestDemo1(t *testing.T) {
    counter := 0
    for i := 0; i < 5000; i++ {
        go func() {
            counter++
        }()
    }
    time.Sleep(1 * time.Second)
    t.Logf("counter = %d", counter)
}

结果

=== RUN   TestDemo1
    a1_test.go:18: counter = 4663
--- PASS: TestDemo1 (1.00s)
PASS

多试几次,结果一直是小于 5000 的不定值。
竞争状态下程序行为的图像表示
image

加锁

将刚刚的代码稍作改动

func TestDemo2(t *testing.T) {
    var mut sync.Mutex // 声明锁
    counter := 0
    for i := 0; i < 5000; i++ {
        go func() {
            mut.Lock() // 加锁
            counter++
            mut.Unlock() // 解锁
        }()
    }
    time.Sleep(1 * time.Second)
    t.Logf("counter = %d", counter)
}

结果

=== RUN   TestDemo2
    a1_test.go:35: counter = 5000
--- PASS: TestDemo2 (1.01s)
PASS

counter = 5000,返回的结果对了。

这就是互斥锁,在代码上创建一个临界区(critical section),保证串行操作(同一时间只有一个 goroutine 执行临界区代码)。

阻塞

那么互斥锁是怎么串行的呢?把每一步的执行过程打印出来看下

func TestDemo3(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        mut.Unlock()
        log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    mut.Lock()
    log.Println("goroutine A Lock")
    counter = 2
    log.Println("goroutine A counter =", counter)
    mut.Unlock()
    log.Println("goroutine A Unlock")
}

结果

=== RUN   TestDemo3
2020/09/30 22:14:00 goroutine B Lock
2020/09/30 22:14:00 goroutine B counter = 1
2020/09/30 22:14:05 goroutine B Unlock
2020/09/30 22:14:05 goroutine A Lock
2020/09/30 22:14:05 goroutine A counter = 2
2020/09/30 22:14:05 goroutine A Unlock
--- PASS: TestDemo3 (5.00s)
PASS

通过每个操作记录下来的时间可以看出,goroutine A 的 Lock 一直阻塞到了 goroutine B 的 Unlock。
image

解锁

这时候有个疑问,那 goroutine B 上的锁,goroutine A 能解锁吗?修改一下刚才的代码,试一下

func TestDemo5(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        //mut.Unlock()
        //log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    mut.Unlock()
    log.Println("goroutine A Unlock")
    counter = 2
    log.Println("goroutine A counter =", counter)
    time.Sleep(2 * time.Second)
}

结果

=== RUN   TestDemo5
2020/09/30 22:15:03 goroutine B Lock
2020/09/30 22:15:03 goroutine B counter = 1
2020/09/30 22:15:04 goroutine A Unlock
2020/09/30 22:15:04 goroutine A counter = 2
--- PASS: TestDemo5 (3.01s)
PASS

测试通过,未报错,counter 的值也被成功修改,证明B上的锁,是可以被A解开的。

再进一步,goroutine A 不解锁,直接修改已经被 goroutine B 锁住的 counter 的值可以吗?试一下

func TestDemo6(t *testing.T) {
    var mut sync.Mutex
    counter := 0
    go func() {
        mut.Lock()
        log.Println("goroutine B Lock")
        counter = 1
        log.Println("goroutine B counter =", counter)
        time.Sleep(5 * time.Second)
        mut.Unlock()
        log.Println("goroutine B Unlock")
    }()
    time.Sleep(1 * time.Second)
    //log.Println("goroutine A Unlock")
    //mut.Unlock()
    counter = 2
    log.Println("goroutine A counter =", counter)
    time.Sleep(10 * time.Second)
}

结果

=== RUN   TestDemo6
2020/09/30 22:15:43 goroutine B Lock
2020/09/30 22:15:43 goroutine B counter = 1
2020/09/30 22:15:44 goroutine A counter = 2
2020/09/30 22:15:48 goroutine B Unlock
--- PASS: TestDemo6 (11.00s)
PASS

测试通过,未报错,证明B上的锁,A可以不用解锁直接改。

延伸

锁的两种通常处理方式

  • 一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);
  • 还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做互斥锁。

饥饿模式

当互斥锁不断地试图获得一个永远无法获得的锁时,它可能会遇到饥饿问题。
在版本1.9中,Go通过添加一个新的饥饿模式来解决先前的问题,所有等待锁定超过一毫秒的 goroutine,也称为有界等待,将被标记为饥饿。当标记为饥饿时,解锁方法现在将把锁直接移交给第一位等待着。

读写锁

读写锁和上面的多也差不多,有这么几种情况

  • 在写锁已被锁定的情况下试图锁定写锁,会阻塞当前的 goroutine。
  • 在写锁已被锁定的情况下试图锁定读锁,会阻塞当前的 goroutine。
  • 在读锁已被锁定的情况下试图锁定写锁,会阻塞当前的 goroutine。
  • 在读锁已被锁定的情况下试图锁定读锁,不会阻塞当前的 goroutine。

panic错误

无论是互斥锁还是读写锁在程序运行时一定是成对的,不然就会引发不可恢复的panic。

总结

  1. 锁一定要用对地方,特别是要注意Lock产生的阻塞对性能的影响。
  2. 在各种程序的逻辑分支下,都要确保锁的成对出现。
  3. 读写锁是对互斥锁的一个扩展,提高了程序的可读性。
  4. 临界区是需要每个 goroutine 主动遵守的,说白了就是每个 goroutine 的代码都存在 Lock。

文章示例代码

Sown专栏地址:https://segmentfault.com/blog/sown

查看原文

赞 8 收藏 5 评论 0

savokiss 赞了文章 · 6月25日

构建基于 iOS 模拟器的前端调试方案

封面

作者:imyzf

本文将为大家介绍自动化控制 iOS 模拟器的原理,为开发基于 iOS 模拟器的前端调试方案提供帮助。

我们在开发 iOS App 内的前端页面时,有一个很大的痛点,页面无法使用 Safari Inspector 等工具调试。遇到了问题,我们只能想办法加 vConsole,或者注入 Weinre,或者盲改,实在不行就找客户端同学手动打包调试,总之排查问题的路途非常艰难。

在参考了 RN 和 Weex 等跨平台框架的开发工具后,我们发现使用模拟器调试是解决该问题的很好方法,我们将前端页面放到模拟器的 App 中运行,苹果就不会对其有限制,允许我们使用 Safari Inspector 调试了。

Safari Inspector 是和 Chrome Devtools 类似的调试工具,由 Safari 浏览器自带,支持以下功能:

Safari Inspector 功能

  • 检查页面元素
  • 查看网络请求
  • 断点调试
  • 存储管理(Local Storage,Cookies 等)
  • ……

这些功能是 vConsole、Weinre 等工具无法比拟的,可以帮助我们快速定位问题。

基于这些原理,我们内部已经开发了一款工具,部分功能视频可以点此预览。但由于该工具和内部业务耦合较深,目前暂无开源计划。

前提条件

介绍这套方案之前,我们需要了解一下方案的前提条件:

  • 装有 macOS 和 Xcode 的电脑:由于苹果的限制,模拟器和 Xcode 只能在 macOS 上运行。Xcode 直接在 App Store 中安装即可,十分简单,无需其他操作。
  • 为模拟器构建的 App 包:由于模拟器是基于 x86 架构的,需要客户端开发同学提供为模拟器构建的包,和在手机上安装的包会有所不同。
  • 支持 URL Scheme 唤起的 App:承载前端页面的 App 必须支持用协议唤起并打开页面,才能用工具实现自动化,否则只能在 App 内手动点击相关链路打开页面。

总体流程

整体流程图

我们的模拟器调试方案整体流程如上图所示:

  1. 获取设备列表,提供给用户选择
  2. 检查模拟器状态,如果没有启动,就启动该模拟器
  3. 检查是否安装对应的 App,如果没有安装,就下载安装包进行安装
  4. 启动 App,并打开需要调试的页面
  5. 根据页面类型,使用对应的工具进行调试(例如 Safari Inspector)

核心工具

我们在实现本方案时,主要基于以下工具:

  • xcrun:Xcode 提供了一个命令行工具xcrun对开发相关的功能进行控制,是一系列工具的集合。
  • simctlxcrun提供了一个子命令simctl用于控制模拟器,提供了模拟器的启动、关闭、安装应用、打开 URL 等功能。可以通过直接运行xcrun simctl查看帮助文档。
  • node-simctl:由 Appium 提供的simctl 工具的 JS 封装。由于前端的方案一般都是基于 node.js 开发的,所以可以使用 node-simctl 包更方便地控制模拟器。不过由于node-simctl只提供了部分功能的封装,我们依然需要手动调用xcrun命令来实现更多功能。

模拟器控制

在本方案中,最重要的部分就是对模拟器的控制。

前期准备

用户通过 App Store 安装完 Xcode 后,第一次运行需要同意苹果的许可协议,然后自动安装一些组件,之后才可以正常使用。为了提高易用性,我们希望自动处理这个过程,而不是告诉用户,安装 Xcode 后要采取一些操作。

首先我们可以尝试运行一次 xcrun simctl命令,如果用户第一次运行,错误信息中会提醒用户手动运行xcodebuild -license接受许可,所以我们可以在错误信息中搜索xcodebuild -license字符串,如果有找到,就自动动运行xcodebuild -license accept命令,帮助用户自动接受许可。这里要注意的是,运行该命令需要 root 权限,可以使用sudo-prompt等包提权运行命令。

第一次运行

获取设备列表

我们可以直接使用 node-simctl 的getDevices()函数获取本地安装的所有设备列表,比调用命令行更方便,可以直接获取到一个对象,不需要自己解析,对象部分结构如下:

{
    '13.4': [
        {
            sdk: '13.4',
            dataPath: '/Users/xx/Library/Developer/CoreSimulator/Devices/xxx/data',
            logPath: '/Users/xx/Library/Logs/xxx',
            udid: 'C1AA9736-XXX-YYY-ZZZ-2A4A674B6B21',
            isAvailable: true,
            deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-11-Pro-Max',
            state: 'Shutdown',
            name: 'iPhone 11 Pro Max',
            platform: 'iOS'
        }
    ]
]

这里不仅包含了 iPhone,还有 Apple Watch 和 Apple TV 等设备,我们可以遍历返回结果,通过name字段进行过滤,因为一般我们只需要在 iPhone 中进行调试。

启动设备

首先我们要判断设备是否已经启动,我们可以通过 xcrun simctl bootstatus ${deviceId}命令获取设备状态(这里的 deviceId 即上面获取设备列表得到的udid),但是如果设备没有启动,这个命令会一直等待,不会退出,所以我们可以通过这个特征,基于命令是否超时(例如 1000ms 未返回结果)来判断设备是否启动。

接下来,就可以直接用xcrun instruments -w ${deviceId}命令,启动对应的设备了。

代码示例:

let status = '';
try {
    status = execSync(
        `xcrun simctl bootstatus ${deviceId}`,
        { timeout: 1000 }
    );
} catch (error) {
    // 如果模拟器未启动,会一直等待,然后超时 kill,抛出一个 ETIMEDOUT 异常
    if (error.code !== 'ETIMEDOUT') {
        throw error
    }
}
// 检查是否启动
if (status.indexOf('Device already booted') < 0) {
    console.log('正在启动模拟器……')
    execSync(`xcrun instruments -w ${deviceId}`)
}

安装 App

模拟器的安装包是一个以.app为结尾命名的文件夹,和 macOS 应用类似,而不是 iPhone 真机上安装使用的.ipa包。所以安装包需要先用zip等工具进行打包上传到服务器,安装前下载到本地解压,使用 node-simctl 的installApp()方法进行安装。

App 检查和启动

对于用户是否安装了 App,其实是在通过分析唤起 App 的错误信息来判断的。如果 App 未安装,会在唤起的时候会报错,错误信息中包含了domain=NSOSStatusErrorDomain字符串,表示 App 没有安装,这个时候我们去调用上面的安装流程即可。

NSOSStatusErrorDomain

整个流程中最重要的一步是如何将我们的页面在 App 中打开,实际上很简单,只需要 App 本身支持类似 cloudmusic://open?url=xxx这样的 URL Scheme 即可。我们通过 node-simctl 的openUrl()方法直接调用 scheme,模拟器便会帮我们启动关联的 App,然后需要 App 根据接收到的 Scheme 参数,帮我们打开需要调试的页面。

代码示例:

try {
    await simctl.openUrl(deviceId, url)
} catch (error) {
    // 没有安装 App,打开协议会报 NSOSStatusErrorDomain
    if (error.message.indexOf('domain=NSOSStatusErrorDomain') >= 0) {
        await simctl.installApp(deviceId, appPath)
        await simctl.openUrl(deviceId, url)
    } else {
        throw error
    }
}

启动调试器

在模拟器中打开调试页面以后,对于 RN 页面,我们可以用 React Native Debugger 等工具调试。对于 H5 页面,我们可以从 Safari 菜单中打开 Inspector调试(如果没有“开发”菜单,请在 Safari 偏好设置 - 高级 - 选中在菜单栏中线显示“开发”菜单)。

Safari 开发菜单

当然这一步也可以实现自动化,需要借助 Apple Script 搜索 Safari 菜单中的关键字并模拟点击,有点复杂,并且随着系统升级可能会失效,可以参考网上的一些讨论

方案扩展

至此,我们已经了解了如何控制模拟器,实现最基本的功能,但是我们还可以对方案进行扩展实现,提高易用性。

接入 CI 服务

客户端会定期发布新版本,加入新的功能,所以我们也需要保持调试用的包为较新版本。一般客户端团队都会搭建自己的 CI 服务(例如 Jenkins)进行打包,所以我们可以进行接入,自动下载和安装最新的包。甚至我们可以拉取 CI 服务器上的包列表,实现安装历史版本,回归调试一些功能。

需要注意的是,客户端团队一般只针对 ARM 架构打包,所以需要在 CI 上新增 x86 构建目标,构建产物才能成功在模拟器上运行。

多 App 支持

随着公司业务范围的拓展,我们可能需要在多个 App 内调试页面,通过指定以下两点,可以实现多 App 的适配:

  1. URL Scheme:通过指定不同的 Scheme,可以在不同的 App 中打开页面
  2. Bundle ID:类似com.netease.cloudmusic这样的字符串,是 App 的唯一标识,可以通过这个 ID 来进行 App 的启动、终止、卸载等操作

总结

到此为止,我们介绍了构建一套基于 iOS 模拟器的前端调试方案的基本原理,基于以上内容,我们可以结合 commander 和 inquirer 开发出一套 CLI 工具,也可以结合 Electron 开发一套 GUI 工具,为开发提效。如果你有更多的想法或者相关经验,也欢迎在评论区与我们交流~

本文发布自 网易云音乐前端团队,文章未经授权禁止任何形式的转载。我们一直在招人,如果你恰好准备换工作,又恰好喜欢云音乐,那就 加入我们
查看原文

赞 11 收藏 4 评论 2

savokiss 赞了文章 · 6月10日

互动直播中的前端技术 -- 即时通讯

本文作者:吴杰

前言

在疫情期间,上班族开启了远程办公,体验了各种远程办公软件。老师做起了主播,学生们感受到了被钉钉支配的恐惧,歌手们开启了在线演唱会,许多综艺节目也变成了在线直播。在这全民互动直播的时期,我们来聊聊互动直播中的即时通讯技术在前端中的使用。

即时通讯技术

即时通讯(Instant Messaging,简称IM)是一个实时通信系统,允许两人或多人使用网络实时的传递文字消息、文件、语音与视频交流。如何来实现呢,通常我们会使用服务器推送技术来实现。常见的有以下几种实现方式。

轮询(polling)

这是一种我们几乎都用到过的的技术实现方案。客户端和服务器之间会一直进行连接,每隔一段时间就询问一次。前端通常采取setInterval或者setTimeout去不断的请求服务器数据。

优点:实现简单,适合处理的异步查询业务。

缺点:轮询时间通常是死的,太长就不是很实时,太短增加服务器端的负担。不断的去请求没有意义的更新的数据也是一种浪费服务器资源的做法。

长轮询(long-polling)

客户端发送一个请求到服务端,如果服务端没有新的数据,就保持住这个连接直到有数据。一旦服务端有了数据(消息)给客户端,它就使用这个连接发送数据给客户端。接着连接关闭。

优点:对比轮询做了优化,有较好的时效性。

缺点:占较多的内存资源与请求数。

iframe流

iframe流就是在浏览器中动态载入一个iframe, 让它的地址指向请求的服务器的指定地址(就是向服务器发送了一个http请求),然后在浏览器端创建一个处理数据的函数,在服务端通过iframe与浏览器的长连接定时输出数据给客户端,iframe页面接收到这个数据就会将它解析成代码并传数据给父页面从而达到即时通讯的目的。

优点:对比轮询做了优化,有较好的时效性。

缺点:兼容性与用户体验不好。服务器维护一个长连接会增加开销。一些浏览器的的地址栏图标会一直转菊花。

Server-sent Events(sse)

sse与长轮询机制类似,区别是每个连接不只发送一个消息。客户端发送一个请求,服务端保持这个连接直到有新消息发送回客户端,仍然保持着连接,这样连接就可以消息的再次发送,由服务器单向发送给客户端。

优点:HTML5 标准;实现较为简单;一个连接可以发送多个数据。

缺点:兼容性不好(IE,Edge不支持);服务器只能单向推送数据到客户端。

WebSocket

HTML5 WebSocket规范定义了一种API,使Web页面能够使用WebSocket协议与远程主机进行双向通信。与轮询和长轮询相比,巨大减少了不必要的网络流量和等待时间。

WebSocket属于应用层协议。它基于TCP传输协议,并复用HTTP的握手通道。但不是基于HTTP协议的,只是在建立连接之前要借助一下HTTP,然后在第一次握手是升级协议为ws或者wss。

优点:开销小,双向通讯,支持二进制传输。

缺点:开发成本高,需要额外做重连保活。

在互动直播场景下,由于本身的实时性要求高,服务端与客户端需要频繁双向通信,因此与它十分契合。

搭建自己的IM系统

上面简单的概述了下即时通讯的实现技术,接下来我们就聊聊如何实现自己的IM系统。

从零开始搭建IM系统还是一件比较复杂与繁琐的事情。自己搭建推荐基于socket.io来实现。socket.io对即时通讯的封装已经很不错了,是一个比较成熟的库,对不同浏览器做了兼容,提供了各端的方案包括服务端,我们不用关心底层是用那种技术实现进行数据的通信,当然在现代浏览器种基本上是基于WebSocket来实现的。市面上也有不少IM云服务平台,比如云信,借助第三方的服务也可以快速集成。下面就介绍下前端怎么基于socket.io集成开发。

基础的搭建

服务端集成socket.io(有java版本的),服务端即成可以参考下这里,客户端使用socket.io-client集成。
参考socket.io官方api,订阅生命周期与事件,通过订阅的方式或来实现基础功能。在回调函数执行解析包装等逻辑,最终抛给上层业务使用。

import io from 'socket.io-client';
import EventEmitter from 'EventEmitter';
class Ws extends EventEmitter {
    constructor (options) {
        super();
        //...
        this.init();
    }
    init () {
        const socket  = this.link = io('wss://x.x.x.x');
        socket.on('connect', this.onConnect.bind(this));
        socket.on('message', this.onMessage.bind(this));
        socket.on('disconnect', this.onDisconnect.bind.(this);
        socket.on('someEvent', this.onSomeEvent.bind(this));
    }
    onMessage(msg) {
        const data = this.parseData(msg);
        // ...
        this.$emit('message', data);
    }
}

消息收发

与服务器或者其他客户端进行消息通讯时通常会基于业务约定协议来封装解析消息。由于都是异步行为,需要有唯一标识来处理消息回调。这里用自增seq来标记。

发送消息

class Ws extends EventEmitter {
    seq = 0;
    cmdTasksMap = {};
    // ...
    sendCmd(cmd, params) {
        return new Promise((resolve, reject) => {
            this.cmdTasksMap[this.seq] = {
                resolve,
                reject
            };
            const data = genPacket(cmd, params, this.seq++);
            this.link.send({ data });
        });
    }
}

接受消息

class Ws extends EventEmitter {
    // ...
    onMessage(packet) {
        const data = parsePacket(packet);
        if (data.seq) {
            const cmdTask = this.cmdTasksMap[data.seq];
            if (cmdTask) {
                if (data.body.code === 200) {
                    cmdTask.resolve(data.body);
                } else {
                    cmdTask.reject(data.body);
                }
                delete this.cmdTasksMap[data.seq];
            }
        }
    }
}

生产环境中优化

上文只介绍了基础功能的简单封装,在生产环境中使用,还需要对考虑很多因素,尤其是在互动直播场景中,礼物展示,麦序(进行语音通话互动的顺序),聊天,群聊等都强依赖长链接的稳定性,下面就介绍一些兜底与优化措施。

连接保持

为了稳定建立长链接与保持长链接。采用了以下几个手段:

  • 超时处理
  • 心跳包
  • 重连退避机制

超时处理

在实际使用中,并不一定每次发送消息都服务端都有响应,可能在客户端已经出现异常了,我们与服务端的通讯方式都是一问一答。基于这一点,我们可以增加超时逻辑来判断是否是发送成功。然后基于回调上层进行有友好提示,进入异常处理。接下来就进一步改造发送逻辑。

class Ws extends EventEmitter {
    // ...
    sendCmd(cmd, params) {
        return new Promise((resolve, reject) => {
            this.cmdTasksMap[this.seq] = {
                resolve,
                reject
            };
            // 加个定时器
            this.timeMap[this.seq] = setTimeout(() => {
                const err = new newTimeoutError(this.seq);
                reject({ ...err });
            }, CMDTIMEOUT);

            const data = genPacket(cmd, params, this.seq++);
            this.link.send({ data });
        });
    }
    onMessage(packet) {
        const data = parsePacket(packet);
        if (data.seq) {
            const cmdTask = this.cmdTasksMap[data.seq];
            if (cmdTask) {
                clearTimeout(this.timeMap[this.seq]);
                delete this.timeMap[this.seq];
                if (data.body.code === 200) {
                    cmdTask.resolve(data.body);
                } else {
                    cmdTask.reject(data.body);
                }
                delete this.cmdTasksMap[data.seq];
            }
        }
    }
}

心跳包

心跳包: 心跳包就是在客户端和服务器间定时通知对方自己状态的一个自己定义的命令字,按照一定的时间间隔发送,类似于心跳,所以叫做心跳包。

心跳包是检查长链接存活的关键手段,在web端我们通过心跳包是否超时来判断。TCP中已有keepalive选项,为什么要在应用层加入心跳包机制?

  • tcp keepalive检查连接是否存活
  • 应用keepalive检测应用是否正常可响应

举个栗子: 服务端死锁,无法处理任何业务请求。但是操作系统仍然可以响应网络层keepalive包。所以我们通常使用空内容的心跳包并设定合适的发送频率与超时时间来作为连接的保持的判断。

如果服务端只认心跳包作为连接存在判断,那就在连接建立后定时发心跳就行。如果以收到包为判断存活,那就在每次收到消息重置并起个定时器发送心跳包。

class Ws extends EventEmitter {
    // ...
     onMessage(packet) {
        const data = parsePacket(packet);
        if (data.seq) {
            const cmdTask = this.cmdTasksMap[data.seq];
            if (cmdTask) {
                clearTimeout(this.timeMap[this.seq]);
                if (data.body.code === 200) {
                    cmdTask.resolve(data.body);
                } else {
                    cmdTask.reject(data.body);
                }
                delete this.cmdTasksMap[data.seq];
            }
        }
        this.startHeartBeat();
    }
    startHeartBeat() {
        if (this.heartBeatTimer) {
            clearTimeout(this.heartBeatTimer);
            this.heartBeatTimer = null;
        }
        this.heartBeatTimer = setTimeout(() => {
            // 在sendCmd中指定heartbeat类型seq为0,让业务包连续编号
            this.sendCmd('heartbeat').then(() => {
                // 发送成功了就不管
            }).catch((e) => {
                this.heartBeatError(e);
            });
        }, HEARTBEATINTERVAL);
    }
}

重连退避机制

连不上了,重连,还连不上,重连,又连不上,重连。重连是一个保活的手段,但总不能一直重连吧,因此我们要用合理策去重连。

通常服务端会提供lbs(Location Based Services,LBS)接口,来提供最优节点,我们端上要做便是缓存这些地址并设定端上的重连退避机制。按级别次数通常会做以下处理。

  • 重连(超时<X次)
  • 换连接地址重连 (超时>=X次)
  • 重新获取连接地址(X<MAX)
  • 上层处理(超过MAX)

在重连X次后选择换地址,在一个地址失败后,选择重新去拿地址再去循环尝试。具体的尝试次数根据实际业务来定。当然在一次又一次失败中做好异常上报,以便于分析解决问题。

接受消息优化

在高并发的场景下尤其是聊天室场景,我们要做一定的消息合并与缓冲,来避免过多的UI绘制与应用阻塞。
因此要约定好解析协议,服务端与客户端都做消息合并,并设置消息缓冲。示例如下:

Fn.startMsgFlushTimer = function () {
    this.msgFlushTimer = setTimeout(() => {
    const msgs = this.msgBuffer.splice(0, BUFFERSIZE);
    // 回调消息通知
    this.onmsgs(msgs);
    if (!this.msgBuffer.length) {
      this.msgFlushTimer = null;
    } else {
      this.startMsgFlushTimer();
    }
  }, MSGBUFFERINTERVAL);
};

流量优化

持久化存储

在单聊场景中每次都同步全量的会话,历史消息等这是一个很大的代价。此外关闭web也是一种比较容易的操作(基本上就需要重新同步一次)。如果我们用增量的方式去同步就可以减少很多流量。实现增量同步自然想到了web存储。

常用web存储cookie,localStorage,sessionStorage不太能满足我们持久化的场景,然而html5的indexedDB正常好满足我们的需求。IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括JavaScript对象。indexedDB的api直接用可能会比较难受,可以使用Dexie.jsdb.js这些二次封装的库来实现业务的数据层。

在满足持久化存储后, 我们便可以用时间戳,来进行增量同步,在收到消息通知时,存储到web数据库。上层操作获取数据,优先从数据库获取数据,避免总是高频率、高数据量的与服务器通讯。当然敏感性信息不要存在数据库或者增加点破解难度,毕竟所有web本地存储都是能看到的。此外注意下存储大小还是有限制的,每种浏览器可能不一样,但是远大于其他Web本地存储了,只要该放云端的数据放云端(比如云消息),不会有太大问题。

在编码实现上,由于处理消息通知都是异步操作,要维护一个队列保证入库时序。此外要做好降级方案

减少连接数

在Web桌面端的互动直播场景,同一种页面开启了多个tab访问应该是很常见的。业务上也会有多端互踢操作,但是对Web场景如果只能一个页面能进行互动那肯定是不行的,一不小心就不知道切到哪个tab上去了。所以通常会设置一个多端在线的最大数,超过了就踢。因而一个浏览器建立7,8个长链接是一件很寻常的事情,对于服务端资源也是一种极大的浪费。

Web Worker可以为Web内容在后台线程中运行脚本提供了一种简单的方法,线程可以执行任务而不干扰用户界面。并且可以将消息发送到创建它的JavaScript代码, 通过将消息发布到该代码指定的事件处理程序(反之亦然)。虽然Web Worker中不能使用DOM API,但是XHR,WebSocket这些通讯API并没有限制(而且可以操作本地存储)。因此我们可以通过SharedWorker API创建一个执行指定脚本来共享web worker来实现多个tab之前的通讯复用,来达到减少连接数的目的。在兼容性要求不那么高的场景可以尝试一下。

小结

本文介绍了互动直播中的即时通讯技术的在前端中应用,并分享了自己在工作开发中的一些经验,希望对您有所帮助,欢迎探讨。

参考资料

本文发布自 网易云音乐前端团队,文章未经授权禁止任何形式的转载。我们一直在招人,如果你恰好准备换工作,又恰好喜欢云音乐,那就 加入我们
查看原文

赞 9 收藏 4 评论 0

savokiss 赞了文章 · 5月15日

大规格文件的上传优化

作者:TJ

在开发过程中,收到这样一个问题反馈,在网站上传 100 MB 以上的文件经常失败,重试也要等老半天,这就难为需要上传大规格文件的用户了。那么应该怎么做才能快速上传,就算失败了再次发送也能从上次中断的地方继续上传呢?下文为你揭晓答案~

温馨提示:配合 Demo 源码一起阅读效果更佳

整体思路

第一步是结合项目背景,调研比较优化的解决方案。
文件上传失败是老生常谈的问题,常用方案是将一个大文件切片成多个小文件,并行请求接口进行上传,所有请求得到响应后,在服务器端合并所有的分片文件。当分片上传失败,可以在重新上传时进行判断,只上传上次失败的部分,减少用户的等待时间,缓解服务器压力。这就是分片上传文件。

大文件上传

那么如何实现大文件分片上传呢?

流程图如下:

图片

分为以下步骤实现:

1. 文件 MD5 加密

MD5 是文件的唯一标识,可以利用文件的 MD5 查询文件的上传状态。

根据文件的修改时间、文件名称、最后修改时间等信息,通过 spark-md5 生成文件的 MD5。需要注意的是,大规格文件需要分片读取文件,将读取的文件内容添加到 spark-md5 的 hash 计算中,直到文件读取完毕,最后返回最终的 hash 码到 callback 回调函数里面。这里可以根据需要添加文件读取的进度条。

图片

实现方法如下:

// 修改时间+文件名称+最后修改时间-->MD5
md5File (file) {
  return new Promise((resolve, reject) => {
    let blobSlice =
      File.prototype.slice ||
      File.prototype.mozSlice ||
      File.prototype.webkitSlice
    let chunkSize = file.size / 100
    let chunks = 100
    let currentChunk = 0
    let spark = new SparkMD5.ArrayBuffer()
    let fileReader = new FileReader()
    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of', chunks)
      spark.append(e.target.result) // Append array buffer
      currentChunk++
      if (currentChunk < chunks) {
        loadNext()
      } else {
        let cur = +new Date()
        console.log('finished loading')
        // alert(spark.end() + '---' + (cur - pre)); // Compute hash
        let result = spark.end()
        resolve(result)
      }
    }
    fileReader.onerror = function (err) {
      console.warn('oops, something went wrong.')
      reject(err)
    }
    function loadNext () {
      let start = currentChunk * chunkSize
      let end =
        start + chunkSize >= file.size ? file.size : start + chunkSize
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }
    loadNext()
  })
}

2. 查询文件状态

前端得到文件的 MD5 后,从后台查询是否存在名称为 MD5 的文件夹,如果存在,列出文件夹下所有文件,得到已上传的切片列表,如果不存在,则已上传的切片列表为空。
已上传分片列表

// 校验文件的MD5
checkFileMD5 (file, fileName, fileMd5Value, onError) {
  const fileSize = file.size
  const { chunkSize, uploadProgress } = this
  this.chunks = Math.ceil(fileSize / chunkSize)
  return new Promise(async (resolve, reject) => {
    const params = {
      fileName: fileName,
      fileMd5Value: fileMd5Value,
    }
    const { ok, data } = await services.checkFile(params)
    if (ok) {
      this.hasUploaded = data.chunkList.length
      uploadProgress(file)
      resolve(data)
    } else {
      reject(ok)
      onError()
    }
  })
}

3. 文件分片

文件上传优化的核心就是文件分片,Blob 对象中的 slice 方法可以对文件进行切割,File 对象是继承 Blob 对象的,因此 File 对象也有 slice 方法。

定义每一个分片文件的大小变量为 chunkSize,通过文件大小 FileSize 和分片大小 chunkSize 得到分片数量 chunks,使用 for 循环和 file.slice() 方法对文件进行分片,序号为 0 - n,和已上传的切片列表做比对,得到所有未上传的分片,push 到请求列表 requestList。

图片

async checkAndUploadChunk (file, fileMd5Value, chunkList) {
  let { chunks, upload } = this
  const requestList = []
  for (let i = 0; i < chunks; i++) {
    let exit = chunkList.indexOf(i + '') > -1
    // 如果已经存在, 则不用再上传当前块
    if (!exit) {
      requestList.push(upload(i, fileMd5Value, file))
    }
  }
  console.log({ requestList })
  const result =
    requestList.length > 0
      ? await Promise.all(requestList)
        .then(result => {
          console.log({ result })
          return result.every(i => i.ok)
        })
        .catch(err => {
          return err
        })
      : true
  console.log({ result })
  return result === true
}

4. 上传分片

调用 Promise.all 并发上传所有的切片,将切片序号、切片文件、文件 MD5 传给后台。

后台接收到上传请求后,首先查看名称为文件 MD5 的文件夹是否存在,不存在则创建文件夹,然后通过 fs-extra 的 rename 方法,将切片从临时路径移动切片文件夹中,结果如下:

上传分片

当全部分片上传成功,通知服务端进行合并,当有一个分片上传失败时,提示“上传失败”。在重新上传时,通过文件 MD5 得到文件的上传状态,当服务器已经有该 MD5 对应的切片时,代表该切片已经上传过,无需再次上传,当服务器找不到该 MD5 对应的切片时,代表该切片需要上传,用户只需上传这部分切片,就可以完整上传整个文件,这就是文件的断点续传。

图片

// 上传chunk
upload (i, fileMd5Value, file) {
  const { uploadProgress, chunks } = this
  return new Promise((resolve, reject) => {
    let { chunkSize } = this
    // 构造一个表单,FormData是HTML5新增的
    let end =
      (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize
    let form = new FormData()
    form.append('data', file.slice(i * chunkSize, end)) // file对象的slice方法用于切出文件的一部分
    form.append('total', chunks) // 总片数
    form.append('index', i) // 当前是第几片
    form.append('fileMd5Value', fileMd5Value)
    services
      .uploadLarge(form)
      .then(data => {
        if (data.ok) {
          this.hasUploaded++
          uploadProgress(file)
        }
        console.log({ data })
        resolve(data)
      })
      .catch(err => {
        reject(err)
      })
  })
}

5. 上传进度

虽然分片批量上传比大文件单次上传会快很多,也还是有一段加载时间,这时应该加上上传进度的提示,实时显示文件上传进度。

原生 Javascript 的 XMLHttpRequest 有提供 progress 事件,这个事件会返回文件已上传的大小和总大小。项目使用 axios 对 ajax 进行封装,可以在 config 中增加 onUploadProgress 方法,监听文件上传进度。

上传进度

const config = {
  onUploadProgress: progressEvent => {
    var complete = (progressEvent.loaded / progressEvent.total * 100 | 0) + '%'
  }
}
services.uploadChunk(form, config)

6. 合并分片

上传完所有文件分片后,前端主动通知服务端进行合并,服务端接受到这个请求时主动合并切片,通过文件 MD5 在服务器的文件上传路径中找到同名文件夹。从上文可知,文件分片是按照分片序号命名的,而分片上传接口是异步的,无法保证服务器接收到的切片是按照请求顺序拼接。所以应该在合并文件夹里的分片文件前,根据文件名进行排序,然后再通过 concat-files 合并分片文件,得到用户上传的文件。至此大文件上传就完成了。

merge

图片

Node 端代码:

// 合并文件
exports.merge = {
  validate: {
    query: {
      fileName: Joi.string()
        .trim()
        .required()
        .description('文件名称'),
      md5: Joi.string()
        .trim()
        .required()
        .description('文件md5'),
      size: Joi.string()
        .trim()
        .required()
        .description('文件大小'),
    },
  },
  permission: {
    roles: ['user'],
  },
  async handler (ctx) {
    const { fileName, md5, size } = ctx.request.query
    let { name, base: filename, ext } = path.parse(fileName)
    const newFileName = randomFilename(name, ext)
    await mergeFiles(path.join(uploadDir, md5), uploadDir, newFileName, size)
      .then(async () => {
        const file = {
          key: newFileName,
          name: filename,
          mime_type: mime.getType(`${uploadDir}/${newFileName}`),
          ext,
          path: `${uploadDir}/${newFileName}`,
          provider: 'oss',
          size,
          owner: ctx.state.user.id,
        }
        const key = encodeURIComponent(file.key)
          .replace(/%/g, '')
          .slice(-100)
        file.url = await uploadLocalFileToOss(file.path, key)
        file.url = getFileUrl(file)
        const f = await File.create(omit(file, 'path'))
        const files = []
        files.push(f)
        ctx.body = invokeMap(files, 'toJSON')
      })
      .catch(() => {
        throw Boom.badData('大文件分片合并失败,请稍候重试~')
      })
  },
}

总结

本文讲述了大规格文件上传优化的一些做法,总结为以下 4 点:

  1. Blob.slice 将文件切片,并发上传多个切片,所有切片上传后告知服务器合并,实现大文件分片上传;
  2. 原生 XMLHttpRequest 的 onprogress 对切片上传进度的监听,实时获取文件上传进度;
  3. spark-md5 根据文件内容算出文件 MD5,得到文件唯一标识,与文件上传状态绑定;
  4. 分片上传前通过文件 MD5 查询已上传切片列表,上传时只上传未上传过的切片,实现断点续传。

参照 Demo 源码 可快速上手上述功能,希望本文能对你有所帮助,感谢阅读 ❤️


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

欢迎关注凹凸实验室公众号

查看原文

赞 60 收藏 46 评论 4

savokiss 发布了文章 · 5月7日

动图学CS: 有用的 Git 命令(上)

尽管 Git 是一个非常强大的工具,但是我相信大部分同学有时候学起 Git 来,感觉很难搞~ 笔者总是习惯于在脑海中重现学习的知识,Git 也一样:当我们执行了切换分支命令,分支之间是如何交互的?又是如何影响历史提交的?当我在 master 分支上执行了强制 resetforce push 到了远端 ,又把 .git 文件夹删掉,我的同事为什么会哭??

于是就有了将这些命令做成动画的想法!由于篇幅有限,本文主要覆盖一些常用命令的默认行为~

MergeRebaseResetRevertCherry-PickFetchPullReflog

合并(Merging)

使用多个分支可以方便我们隔离彼此,也可以防止意外提交到生产环境,对分支模型感兴趣的小伙伴也可以看笔者之前的文章:

使用 git-flow 自动化你的 git 工作流

当我们的某个功能开发完成时,就需要将这些更改应用到生产环境上。其中一个应用的方式就是 git merge !而这个命令有两种类型:一种叫 fast-forward 方式,一种叫 no-fast-forward 方式。

现在不太懂没关系,我们先来对比下这两个选项。

以下例子中将 master 称作 主分支当前分支

Fast-forward (--ff)

一个 fast-forward merge 可以被用于:当 主分支 相比 要被合并的分支 没有额外的提交时。Git 是。。懒惰的,它会首先尝试使用这个最简单的 fast-forward 选项。这种方式不会创建新的 commit,可以说它只是把我们的提交和 HEAD 指针挪了一个位置。

1-merge-ff.gif

完美!现在我们所有的更改都从 dev 分支合并到 master 分支了~

No-fast-forward (--no-ff)

主分支没有额外的提交当然是最好的情况,但是在多人协作的情况下,这种情况当然就很少见了,毕竟大家都在加班嘛~ 那么如果主分支具有额外的提交时,在 merge 时,git 就会使用 no-fast-forward 选项。

在使用 no-fast-forward 选项时,Git 就在当前分支创建了一个新的 合并提交。而这个提交的上一级同时指向了当分支和要合并的分支!具体见动图:

1-merge-no-ff.gif

没啥大不了的,完美合并!现在 master 分支就包含 dev 分支中的所有提交了。

合并冲突(Merge Conflicts)

尽管 Git 对于合并的默认行为非常棒,但是总有需要我们自己解决的时候。比如说,当两个分支上都有新的提交,又同时修改了同一个文件同一行的内容,或者一个分支上删除了一个文件,而另一个分支却修改了那个文件等等。

这些情况下,Git 就会请我们来帮忙啦。假设我们在两个分支上同时修改了 README.md 文件。

2-conflicts.png

如果我们想要将 dev 合并到 master,这就会产生一个冲突(conflict):因为 Git 也不清楚你到底是想要 Hello! 还是 Hey!

所以当我们合并分支时,Git 会告诉我们冲突发生的具体位置。我们需要手动删除不要的地方,保存更改,然后再提交。

2-conflicts-ani.gif

赞!尽管造成冲突非常烦人,但也符合逻辑,机器毕竟是机器,它肯定不能替我们决定需要保留哪块内容吧~

变基(Rebasing)

刚刚我们见识了 git merge 的合并过程。另一种将变更从一个分支应用到另一个分支的方式是:git rebase

关于这两个命令的区别也可以看笔者之前的文章:

带你理解 Git 中的 Merge 和 Rebase

简单来说就是:Merge 保留历史记录,而 Rebase 改写历史记录

git rebase 将提交从一个分支(dev)复制到另一个分支(master)的顶部。

3-rebase.gif

完美,现在我们已经将 dev 的起点设置为新的 master 分支了。

相比 Merge 来说一个很大的不同点是,Git 不会去查找哪个文件需要保留,哪个不需要。我们的 dev 分支可以使用 rebase 来一直追踪最新的 master 分支。这样就不会产生冲突,同时也会有一个线性的 Git 历史记录。

图中的例子是将 master 作为 devbase (基础分支), 在大型项目中,通常我们不会这么做。git rebase会修改项目的历史记录,同时复制的 commit 也会生成新的 hash 值。

当你在 feature 分支上工作,而 master 分支又更新了,这时就可以使用 rebase,无缝地将 master 上的分支更新到你的 feature 分支了!

交互式变基(Interactive Rebase)

在进行变基之前,我们也可以修改之前的提交,这就用到了 交互式变基。交互式变基也适用于你想要修改当前工作分支的某些提交。

一共有 6 种操作可以应用到之前的提交上:

  • reword:修改提交的信息 (commit message)
  • edit:修改提交内容
  • squash: 将某个提交与前一个提交合并
  • fixup: 同 squash,但是丢弃提交信息
  • exec:在想要 rebase 的提交上依次执行某个命令
  • drop: 删除某个提交

好啦!这样,我们就可以完全掌控我们的提交。如果你需要删除某个提交,只需要 drop 就好~

3-rebase-2.gif

或者说如果我们为了干净的历史记录,需要合并多个提交,也没问题:

3-rebase-3.gif

交互式变基给了我们很大的权力来控制提交,即使在你当前工作的分支也没问题。

未完待续

好啦,由于原文篇幅太长,本篇我们先讲了前两个命令:MergeRebase,这两个同时也是 Git 分支操作中最重要的两个命令,下一篇我们继续讲剩下的六个命令~

关注我了解更多哦~

参考文章


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 12 收藏 10 评论 0

savokiss 发布了文章 · 4月23日

图解你身边的 SOLID 原则 - JS 实例版

上次笔者翻译了一篇图解 SOLID 原则

原文见: 图解你身边的 SOLID 原则

过了两天发现有人为那篇文章补充了 JavaScript 例子,看了下例子还不错,这次就顺便也翻译一下哈,部分例子有删改~

关于概念部分就不多说了,看上一篇或者看图就好~ 那么直接进入正题:

S - 单一职责原则

1_s.jpg

例子

我们假设需要验证一个表单,然后将用户保存到数据库中。

不推荐

/**
 * 这个函数的名字就明显违背了单一职责原则
 * 对于表单的验证和用户创建被耦合在一起了
 * 这样写是不推荐的!
 */
function validateAndSaveUser (req) {   
  // 调用外部函数来验证用户表单
  const isFormValid = validateForm(req.name, req.password, req.email)
  // 如果表单合法
  if (isFormValid) {
    doCreateUser(req.name, req.password, req.email) // 创建用户的具体实现
  }
}

推荐

// 验证请求的函数
function validateRequest (req) {
  // 调用外部函数来验证用户表单
  const isFormValid = validateForm(req.name, req.password, req.email)
  // 如果表单合法
  if (isFormValid) {
    createUser(req) // 在另一个模块中实现
  }
}
// 仅仅用来将用户存储到数据库
function createUser (req) {
  doCreateUser(req.name, req.password, req.email) // 具体实现代码
}

上面的修改虽然看起来很小,但是将验证逻辑和用户创建逻辑进行了解耦,而用户创建貌似是个会经常更改的功能,这就为将来的修改提供了便利。

O - 开闭原则

2_o.jpg

例子

假设我们有以下的权限验证函数:

const roles = ["ADMIN", "USER"]
function checkRole (user) {
  if (roles.includes(user.role)) {
    return true
  }
  return false
}
// 角色校验
checkRole("ADMIN") // true
checkRole("Savo") // false

如果我们想要添加一个超级管理员,为了不修改之前的代码(或者说我们本来就无法修改遗留代码),我们可以添加一个新增权限函数:

// 此处的代码无法修改!
const roles = ["ADMIN", "USER"]
function checkRole (user) {
  if (roles.includes(user.role)) {
    return true
  }
  return false
}
// 此处的代码无法修改!
// 我们可以定义一个函数专门用来新增角色
function addRole (role) {
  roles.push(role)
}
// 调用新函数来添加角色
addRole("SUPERUSER")
// 验证角色
checkRole("ADMIN") // true
checkRole("Savo") // false
checkRole("SUPERUSER") // true

L - 里氏替换原则

3_l.jpg

例子

下面以工程师为例子,初级工程师其实就可以被高级工程师替换掉。(没毛病==)

class Engineer {
  constructor (coder) {
    this.coder = coder
    this.writeCode = function () {
      console.log("Coding") // 工程师都会写代码
    }
  }
  // 初级工程师
  Simple (coder) {
    this.writeCode(coder)
  }
  // 高级工程师
  Pro (coder) {
    this.writeCode(coder)
    console.log("Design Architecture") // 高级工程师还需要设计架构~
  }
}
const a = new Engineer("Savokiss")
a.Simple()
// 输出:
// Coding
a.Pro()
// 输出: 
// Coding 
// Design Architecture... 

I - 接口隔离原则

4_i.jpg

例子

不推荐

// 什么情况下都进行验证
class User {
  constructor (username, password) {
    this.initUser(username, password)
  }

  initUser (username, password) {
    this.username = username
    this.password = password
    this.validateUser()
  }

  validateUser () {
    console.log("验证中...") // 添加验证逻辑
  }
}
const user = new User("Savokiss", "123456")
console.log(user)
// 验证中...
// User {
//   validateUser: [Function: validateUser],
//   username: 'Savokiss',
//   password: '123456'
// }

推荐

// 将验证当做一个可选接口
class User {
  constructor (username, password, validate) {
    this.initUser(username, password, validate)
    if (validate) {
      this.validateUser()
    } else {
      console.log("不需要验证逻辑")
    }
  }
  
  initUser (username, password, validate) {
    this.username = username
    this.password = password
    this.validate = validate
  }
  
  validateUser () {
    console.log("验证中...") 
  }
}

// 需要验证的用户
console.log(new User("Savokiss", "123456", true))

// 验证中...
// User {
//   validateUser: [Function: validateUser],
//   username: 'Francesco',
//   password: '123456',
//   validate: true
// }

// 不需要验证的用户
console.log(new User("Guest", "guest", false))

// 不需要验证逻辑
// User {
//   validateUser: [Function: validateUser],
//   username: 'guest',
//   password: 'guest',
//   validate: false
// }

D - 依赖倒置原则

5_d.jpg

例子

不推荐

// http 请求依赖了 setState 函数,即依赖了一个细节
http.get("http://address/api/examples", (res) => {
  this.setState({
    key1: res.value1,
    key2: res.value2,
    key3: res.value3
  })
})

推荐

// http 请求
const httpRequest = (url, state) => {
  http.get(url, (res) => state.setValues(res))
}

// 在另一个函数中设置状态
const state = {
  setValues: (res) => {
    this.setState({
      key1: res.value1,
      key2: res.value2,
      key3: res.value3
    })
  }
}
// 请求时,将 state 作为抽象注入进去
httpRequest("http://address/api/examples", state)

总结

SOLID 原则的主要目标是让任何软件都应该更容易更改,并且更易于理解。

SOLID 原则同时也让你的代码:

  • 更加易于理解
  • 更加易于扩展,同时减少 bug
  • 隔离抽象和实现
  • 更加易于替换实现
  • 更加易于测试

好啦~ 希望本文对你有帮助~

参考文章


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 8 收藏 3 评论 4

savokiss 关注了用户 · 4月22日

ken1992code @chongdianqishi

手绘文档,从源码到设计都能还原
笔记狂人,1/7/30/60 天间隔复习改进
舞蹈领队,年会表演包教包会
DOTA 辅助,队伍粘合剂
拳击自学者,模拟战中所向披靡

关注 5

savokiss 收藏了文章 · 4月22日

【代码规范】SOLID原则简化GIF图

SOLID动图

前几天看到 Savokiss 的图解你身边的SOLID原则,甚是喜欢。文章对面向对象设计原则有深入浅出的理解,其中的简笔画令人印象深刻。
个人对文字超多的博客没有好感,也不能记住大量的文字描述。不如将理论简化成一个简单的GIF:
Apr-18-2020 11-11-46.gif

这样,设计对象的时候,脑海中遍浮现出这个图像,而不用回忆大量的文字描述。这样就成功对知识进行了压缩(当然前提是理解和代码练习,浮于表面的记忆对编程没有帮助)。

制作说明

  • 动画用Keynotes制作,用Windows系统的朋友可以用PPT实现同样效果。
  • GIF录制通过GIPHY CAPTURE完成。
  • 像管理商品一样打理你的知识,多少种多少样清清楚楚,进货单出货单详细记录。商品要不断升级和整理,客人来了要能清晰介绍,坏了要及时维修,时不时瞄一下隔壁大商场卖什么。
  • 直接赋予编程以乐趣,而不是发掘乐趣,更加高效而快乐。
查看原文

savokiss 赞了文章 · 4月22日

【代码规范】SOLID原则简化GIF图

SOLID动图

前几天看到 Savokiss 的图解你身边的SOLID原则,甚是喜欢。文章对面向对象设计原则有深入浅出的理解,其中的简笔画令人印象深刻。
个人对文字超多的博客没有好感,也不能记住大量的文字描述。不如将理论简化成一个简单的GIF:
Apr-18-2020 11-11-46.gif

这样,设计对象的时候,脑海中遍浮现出这个图像,而不用回忆大量的文字描述。这样就成功对知识进行了压缩(当然前提是理解和代码练习,浮于表面的记忆对编程没有帮助)。

制作说明

  • 动画用Keynotes制作,用Windows系统的朋友可以用PPT实现同样效果。
  • GIF录制通过GIPHY CAPTURE完成。
  • 像管理商品一样打理你的知识,多少种多少样清清楚楚,进货单出货单详细记录。商品要不断升级和整理,客人来了要能清晰介绍,坏了要及时维修,时不时瞄一下隔壁大商场卖什么。
  • 直接赋予编程以乐趣,而不是发掘乐趣,更加高效而快乐。
查看原文

赞 2 收藏 1 评论 2

savokiss 赞了文章 · 4月15日

SegmentFault 思否为什么要做技术媒体?

2019 年 7 月思否冷启动了媒体业务并开始组建专业技术编辑团队,去年 12 月我们发布的『中国技术品牌影响力企业榜单』在行业引起了广泛关注。开发者应该已经注意到,前不久我们在社区的导航栏低调上线了资讯板块,意味着思否技术媒体业务正式对外发布了。

SegmentFault 思否技术媒体正式上线

媒体业务的上线其实源于思否公司的初心和使命——

在思否成立之初我们就确立了“改变并提升开发者获取知识的效率,帮助开发者获得成功”的公司使命。除了专业技术,对行业的认知和了解也是“知识”的重要组成部分,影响着开发者的综合素养、职业选择与发展。通过查看源代码可以看到我们对于技术媒体业务的介绍:SegmentFault 思否资讯频道集合了 IT 技术领域最新鲜的行业快讯,深度行业观察和技术人访谈等栏目,致力于记录和推动 IT 技术行业创新,帮助更多的开发者获得认知和能力的提升

同时,我们希望通过 SegmentFault 技术媒体平台可以更好地分享思否对于技术行业的观点和看法,让我们更多地与行业产生连接帮助科技企业与开发者对话

在未来我们也希望通过我们的能力赋能技术媒体行业帮助开发者更好地通过我们的媒体平台发表观点和行业洞见,记录和推动技术行业的发展。


关于思否为什么要做技术媒体,我也访谈了公司另外三位合伙人,我们一起来看看他们的想法 ——

你为什么想要去做技术媒体?

运营合伙人 Nadia于我而言,帮助开发者成长就是我『改变世界』的方式和我的新闻理想。

SegmentFault 思否运营合伙人:于我而言,帮助开发者成长就是我『改变世界』的方式和我的新闻理想。

想去做媒体其实和我的个人经历和理想有很大关系,此前我曾经在传统媒体(《凤凰周刊》、南方报社、广东台)有过 3 年左右的工作 / 实习经历,我相信文字的力量、信息的价值,也体会着个体文字工作者在社会进程中的渺小。

时隔 5 年,媒体格局已经发生翻天地覆的变化,自媒体的飞速发展、信息爆炸……再做媒体这件事,我会希望可以更专注于某一具体领域,具体来说,我希望可以在思否带领媒体团队做真正面向开发者的技术媒体,筛选对开发者有价值的信息、以开发者的视角去分析和解读,并最终作用于他们,为他们创造价值。

前段时间在面试一位技术编辑时,我们偶然聊到媒体工作者的『新闻理想』,白岩松说『新闻有助于让这个世界变得更好』,邵飘萍说『铁肩担道义,妙手著文章』,而于我而言,帮助开发者成长就是我『改变世界』的方式和我的新闻理想。

你希望我们的媒体业务未来在产品上会有什么创新?

产品合伙人 Fen赋能技术内容创作者,加强创作者和读者的互动,让交流更加开放自由。

SegmentFault 产品合伙人 Fen:赋能技术内容创作者,加强创作者和读者的互动,让交流更加开放自由。

从产品角度,未来社区媒体的产品应该:

  1. 承载不同的媒体类型:支持文字、视频、代码、直播、短消息、邮件等媒体,并帮助他们获得更好的展现;
  2. 为作者提供内容分发的渠道:帮助作者产生的内容触达更多的目标读者;
  3. 为读者提供内容筛选的能力:帮助读者快速获取自己需要的内容;
  4. 建立个体与个体互动关系:加强作者和读者的互动,让交流更加开放自由。

谈不上创新,本质上是电子邮件和邮件组的衍生,我觉得 ActivityPub 的一张图可以很好的解释。

ActivityPub

对于我们技术媒体业务有什么建议和期待?

技术合伙人 Joyqi:技术媒体千千万,能让人记住你却不多。做媒体不一定要吸引眼球,但希望能有自己的理念,并一直坚持下去。读者更希望看到的是人的观点,而不只是新闻。

SegmentFault 思否技术合伙人Joyqi:读者更希望看到的是人的观点,而不只是新闻


我们的媒体业务还处在早期,也还有很多不完善的地方,我们非常欢迎各位开发者和从业者给我们反馈建议。

我们也非常欢迎各位社区开发者和各科技厂商的媒体关系同事与我们建立联系,给我们贡献优质的新闻源。

联系邮箱:pr@segmentfault.com

查看原文

赞 11 收藏 2 评论 3

savokiss 发布了文章 · 4月15日

图解你身边的 SOLID 原则

这篇文章我们来简单介绍一下 SOLID 原则(这五个字母代表了面向对象编程的五个基本原则)

我们用身边的事物来举例,让它们更易于理解和记忆。

好啦,开始吧~

S - 单一职责原则

Single Responsibllity Principle - 即 SRP

一个类只能承担一个职责。通俗点儿说就是一个类只能承担一件事,并且只能有一个潜在的原因去更改这个类,否则就违反了单一职责原则。

1_s.jpg

O - 开闭原则

Open/Closed Principle - 即 OCP

软件实体应该对 扩展 开放,对 修改 关闭。允许扩展行为而无需修改源代码。

2_o.jpg

L - 里氏替换原则

Liskov Substitution Principle - 即 LSP

程序中的对象应该可以被其子类实例替换掉,而不会影响程序的正确性。

3_l.jpg

I - 接口隔离原则

Interface Segregation Principle - 即 ISP

使用多个特定细分的接口比单一的总接口要好,不能强迫用户去依赖他们用不到的接口。

4_i.jpg

D - 依赖倒置原则

Dependency Inversion Principle - DIP

程序要依赖于抽象接口,而不是具体实现。

  • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
  • 抽象不应该依赖具体实现,具体实现应该依赖抽象

5_d.jpg

插头不应该依赖具体某种电线,它只需要有线并且能导电。

全文完~希望本文对你理解 SOLID 有帮助啦~

参考文章


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 17 收藏 11 评论 3

savokiss 赞了文章 · 3月31日

【图文详解】200行JS代码,带你实现代码编译器(人人都能学会)


最近看到掘金、前端公众号好多 ES2020 的文章,想说一句:放开我,我还学得动!


先问大家一句,日常项目开发中你能离开 ES6 吗?

一、前言

对于前端同学来说,编译器可能适合神奇的魔盒🎁,表面普通,但常常给我们惊喜。
编译器,顾名思义,用来编译,编译什么呢?当然是编译代码咯🌹。



其实我们也经常接触到编译器的使用场景:

  • React 中 JSX 转换成 JS 代码;
  • 通过 Babel 将 ES6 及以上规范的代码转换成 ES5 代码;
  • 通过各种 Loader 将 Less / Scss 代码转换成浏览器支持的 CSS 代码;
  • 将 TypeScript 转换为 JavaScript 代码。
  • and so on...


使用场景非常之多,我的双手都数不过来了。😄
虽然现在社区已经有非常多工具能为我们完成上述工作,但了解一些编译原理是很有必要的。接下来进入本文主题:200行JS代码,带你实现代码编译器

二、编译器介绍

2.1 程序运行方式

现代程序主要有两种编译模式:静态编译和动态解释。推荐一篇文章《Angular 2 JIT vs AOT》介绍得非常详细。

静态编译

简称 AOT(Ahead-Of-Time)即 提前编译 ,静态编译的程序会在执行前,会使用指定编译器,将全部代码编译成机器码。

(图片来自:https://segmentfault.com/a/1190000008739157


在 Angular 的 AOT 编译模式开发流程如下:

  • 使用 TypeScript 开发 Angular 应用
  • 运行 ngc 编译应用程序

    • 使用 Angular Compiler 编译模板,一般输出 TypeScript 代码
    • 运行 tsc 编译 TypeScript 代码
  • 使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  • 部署应用

动态解释

简称 JIT(Just-In-Time)即 即时编译 ,动态解释的程序会使用指定解释器,一边编译一边执行程序。
(图片来自:https://segmentfault.com/a/1190000008739157


在 Angular 的 JIT 编译模式开发流程如下:

  • 使用 TypeScript 开发 Angular 应用
  • 运行 tsc 编译 TypeScript 代码
  • 使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  • 部署应用

AOT vs JIT

AOT 编译流程:(图片来自:https://segmentfault.com/a/1190000008739157

JIT 编译流程:(图片来自:https://segmentfault.com/a/1190000008739157

特性AOTJIT
编译平台(Server) 服务器(Browser) 浏览器
编译时机Build (构建阶段)Runtime (运行时)
包大小较小较大
执行性能更好-
启动时间更短-

除此之外 AOT 还有以下优点:

  • 在客户端我们不需要导入体积庞大的 angular 编译器,这样可以减少我们 JS 脚本库的大小
  • 使用 AOT 编译后的应用,不再包含任何 HTML 片段,取而代之的是编译生成的 TypeScript 代码,这样的话 TypeScript 编译器就能提前发现错误。总而言之,采用 AOT 编译模式,我们的模板是类型安全的。

2.2 现代编译器工作流程

摘抄维基百科中对 编译器工作流程介绍:

一个现代编译器的主要工作流程如下:
源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标代码(object code)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去判读运行了。

这里更强调了编译器的作用:将原始程序作为输入,翻译产生目标语言的等价程序

编译器三个核心阶段.png

目前绝大多数现代编译器工作流程基本类似,包括三个核心阶段:

  1. 解析(_Parsing_) :通过词法分析和语法分析,将原始代码字符串解析成抽象语法树(Abstract Syntax Tree)
  2. 转换(_Transformation_):对抽象语法树进行转换处理操作;
  3. 生成代码(_Code Generation_):将转换之后的 AST 对象生成目标语言代码字符串。

三、编译器实现

本文将通过 The Super Tiny Compiler 源码解读,学习如何实现一个轻量编译器,最终实现将下面原始代码字符串(Lisp 风格的函数调用)编译成 JavaScript 可执行的代码

Lisp 风格(编译前)JavaScript 风格(编译后)
2 + 2(add 2 2)add(2, 2)
4 - 2(subtract 4 2)subtract(4, 2)
2 + (4 - 2)(add 2 (subtract 4 2))add(2, subtract(4, 2))


话说 The Super Tiny Compiler 号称可能是有史以来最小的编译器,并且其作者 James Kyle 也是 Babel 活跃维护者之一。


让我们开始吧~

3.1 The Super Tiny Compiler 工作流程

现在对照前面编译器的三个核心阶段,了解下 The Super Tiny Compiler  编译器核心工作流程:
The Super Tiny Compiler编译器工作流程.png

图中详细流程如下:

1.执行入口函数,输入原始代码字符串作为参数;

// 原始代码字符串
(add 2 (subtract 4 2))

2.进入解析阶段(Parsing),原始代码字符串通过词法分析器(Tokenizer)转换为词法单元数组,然后再通过 语法分析器(Parser)词法单元数组转换为抽象语法树(Abstract Syntax Tree 简称 AST),并返回;

解析阶段 - 词法分析.png

解析阶段 - 语法分析.png

3.进入转换阶段(Transformation),将上一步生成的 AST 对象 导入转换器(Transformer),通过转换器中的遍历器(Traverser),将代码转换为我们所需的新的 AST 对象

转换阶段.png

4.进入代码生成阶段(Code Generation),将上一步返回的新 AST 对象通过代码生成器(CodeGenerator),转换成 JavaScript Code

代码生成阶段.png

5.代码编译结束,返回 JavaScript Code



上述流程看完后可能一脸懵逼,不过没事,请保持头脑清醒,先有个整个流程的印象,接下来我们开始阅读代码:

3.2 入口方法

首先定义一个入口方法 compiler ,接收原始代码字符串作为参数,返回最终 JavaScript Code:

// 编译器入口方法 参数:原始代码字符串 input
function compiler(input) {
  let tokens = tokenizer(input);
  let ast    = parser(tokens);
  let newAst = transformer(ast);
  let output = codeGenerator(newAst);
  return output;
}

3.3 解析阶段

在解析阶段中,我们定义词法分析器方法tokenizer  和语法分析器方法parser 然后分别实现:

// 词法分析器 参数:原始代码字符串 input
function tokenizer(input) {};

// 语法分析器 参数:词法单元数组tokens
function parser(tokens) {};

词法分析器

词法分析器方法tokenizer 的主要任务:遍历整个原始代码字符串,将原始代码字符串转换为词法单元数组(tokens),并返回。
在遍历过程中,匹配每种字符并处理成词法单元压入词法单元数组,如当匹配到左括号( ( )时,将往词法单元数组(tokens)压入一个词法单元对象{type: 'paren', value:'('})。
词法分析器工作流程.png

// 词法分析器 参数:原始代码字符串 input
function tokenizer(input) {
  let current = 0;  // 当前解析的字符索引,作为游标
  let tokens = [];  // 初始化词法单元数组
  // 循环遍历原始代码字符串,读取词法单元数组
  while (current < input.length) {
    let char = input[current];
    // 匹配左括号,匹配成功则压入对象 {type: 'paren', value:'('}
    if (char === '(') {
      tokens.push({
        type: 'paren',
        value: '('
      });
      current++;
      continue; // 自增current,完成本次循环,进入下一个循环
    }
    // 匹配右括号,匹配成功则压入对象 {type: 'paren', value:')'}
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')'
      });
      current++;
      continue;
    }
    
    // 匹配空白字符,匹配成功则跳过
    // 使用 \s 匹配,包括空格、制表符、换页符、换行符、垂直制表符等
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }
    // 匹配数字字符,使用 [0-9]:匹配
    // 匹配成功则压入{type: 'number', value: value}
    // 如 (add 123 456) 中 123 和 456 为两个数值词法单元
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {
      let value = '';
      // 匹配连续数字,作为数值
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'number', value });
      continue;
    }
    // 匹配形双引号包围的字符串
    // 匹配成功则压入 { type: 'string', value: value }
    // 如 (concat "foo" "bar") 中 "foo" 和 "bar" 为两个字符串词法单元
    if (char === '"') {
      let value = '';
      char = input[++current]; // 跳过左双引号
      // 获取两个双引号之间所有字符
      while (char !== '"') {
        value += char;
        char = input[++current];
      }
      char = input[++current];// 跳过右双引号
      tokens.push({ type: 'string', value });
      continue;
    }
    // 匹配函数名,要求只含大小写字母,使用 [a-z] 匹配 i 模式
    // 匹配成功则压入 { type: 'name', value: value }
    // 如 (add 2 4) 中 add 为一个名称词法单元
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';
      // 获取连续字符
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }
      tokens.push({ type: 'name', value });
      continue;
    }
    // 当遇到无法识别的字符,抛出错误提示,并退出
    throw new TypeError('I dont know what this character is: ' + char);
  }
  // 词法分析器的最后返回词法单元数组
  return tokens;
}

语法分析器

语法分析器方法parser 的主要任务:将词法分析器返回的词法单元数组,转换为能够描述语法成分及其关系的中间形式(抽象语法树 AST)。
语法分析器工作流程.png

// 语法分析器 参数:词法单元数组tokens
function parser(tokens) {
  let current = 0; // 设置当前解析的词法单元的索引,作为游标
  // 递归遍历(因为函数调用允许嵌套),将词法单元转成 LISP 的 AST 节点
  function walk() {
    // 获取当前索引下的词法单元 token
    let token = tokens[current]; 

    // 数值类型词法单元
    if (token.type === 'number') {
      current++; // 自增当前 current 值
      // 生成一个 AST节点 'NumberLiteral',表示数值字面量
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    // 字符串类型词法单元
    if (token.type === 'string') {
      current++;
      // 生成一个 AST节点 'StringLiteral',表示字符串字面量
      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 函数类型词法单元
    if (token.type === 'paren' && token.value === '(') {
      // 跳过左括号,获取下一个词法单元作为函数名
      token = tokens[++current];

      let node = {
        type: 'CallExpression',
        name: token.value,
        params: []
      };

      // 再次自增 current 变量,获取参数词法单元
      token = tokens[++current];

      // 遍历每个词法单元,获取函数参数,直到出现右括号")"
      while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) {
        node.params.push(walk());
        token = tokens[current];
      }

      current++; // 跳过右括号
      return node;
    }
    // 无法识别的字符,抛出错误提示
    throw new TypeError(token.type);
  }

  // 初始化 AST 根节点
  let ast = {
    type: 'Program',
    body: [],
  };

  // 循环填充 ast.body
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // 最后返回ast
  return ast;
}

3.4 转换阶段

在转换阶段中,定义了转换器 transformer 函数,使用词法分析器返回的 LISP 的 AST 对象作为参数,将 AST 对象转换成一个新的 AST 对象。


为了方便代码组织,我们定义一个遍历器 traverser 方法,用来处理每一个节点的操作。

// 遍历器 参数:ast 和 visitor
function traverser(ast, visitor) {
  // 定义方法 traverseArray 
  // 用于遍历 AST节点数组,对数组中每个元素调用 traverseNode 方法。
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // 定义方法 traverseNode
  // 用于处理每个 AST 节点,接受一个 node 和它的父节点 parent 作为参数
  function traverseNode(node, parent) {
    // 获取 visitor 上对应方法的对象
    let methods = visitor[node.type];
    // 获取 visitor 的 enter 方法,处理操作当前 node
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    switch (node.type) {
      // 根节点
      case 'Program':
        traverseArray(node.body, node);
        break;
      // 函数调用
      case 'CallExpression':
        traverseArray(node.params, node);
        break;
      // 数值和字符串,忽略
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 当遇到无法识别的字符,抛出错误提示,并退出
      default:
        throw new TypeError(node.type);
    }
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }
  // 首次执行,开始遍历
  traverseNode(ast, null);
}

在看遍历器traverser 方法时,建议结合下面介绍的转换器transformer 方法阅读:

// 转化器,参数:ast
function transformer(ast) {
  // 创建 newAST,与之前 AST 类似,Program:作为新 AST 的根节点
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 通过 _context 维护新旧 AST,注意 _context 是一个引用,从旧的 AST 到新的 AST。
  ast._context = newAst.body;

  // 通过遍历器遍历 处理旧的 AST
  traverser(ast, {
    // 数值,直接原样插入新AST,类型名称 NumberLiteral
    NumberLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    // 字符串,直接原样插入新AST,类型名称 StringLiteral
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    // 函数调用
    CallExpression: {
      enter(node, parent) {
        // 创建不同的AST节点
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };

        // 函数调用有子类,建立节点对应关系,供子节点使用
        node._context = expression.arguments;

        // 顶层函数调用算是语句,包装成特殊的AST节点
        if (parent.type !== 'CallExpression') {

          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }
        parent._context.push(expression);
      },
    }
  });
  return newAst;
}

重要一点,这里通过 _context 引用来维护新旧 AST 对象,管理方便,避免污染旧 AST 对象。

3.5 代码生成

接下来到了最后一步,我们定义代码生成器codeGenerator 方法,通过递归,将新的 AST 对象代码转换成 JavaScript 可执行代码字符串。

// 代码生成器 参数:新 AST 对象
function codeGenerator(node) {

  switch (node.type) {
    // 遍历 body 属性中的节点,且递归调用 codeGenerator,按行输出结果
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // 表达式,处理表达式内容,并用分号结尾
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';'
      );

    // 函数调用,添加左右括号,参数用逗号隔开
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // 标识符,返回其 name
    case 'Identifier':
      return node.name;
    // 数值,返回其 value
    case 'NumberLiteral':
      return node.value;

    // 字符串,用双引号包裹再输出
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 当遇到无法识别的字符,抛出错误提示,并退出
    default:
      throw new TypeError(node.type);
  }
}

3.6 编译器测试

截止上一步,我们完成简易编译器的代码开发。接下来通过前面原始需求的代码,测试编译器效果如何:

const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const source = "(add 2 (subtract 4 2))";
const target = compiler(source); // "add(2, (subtract(4, 2));"

const result = eval(target); // Ok result is 4

3.7 工作流程小结

总结 The Super Tiny Compiler 编译器整个工作流程:
1、input => tokenizer => tokens
2、tokens => parser => ast
3、ast => transformer => newAst
4、newAst => generator => output

其实多数编译器的工作流程都大致相同:
TheSuperTinyCompiler编译器工作流程(方法实现).png

四、手写 Webpack 编译器

根据之前介绍的 The Super Tiny Compiler编译器核心工作流程,再来手写 Webpack 的编译器,会让你有种众享丝滑的感觉~


话说,有些面试官喜欢问这个呢。当然,手写一遍能让我们更了解 Webpack 的构建流程,这个章节我们简要介绍一下。

4.1 Webpack 构建流程分析

从启动构建到输出结果一系列过程:

Webpack构建流程.png

1.初始化参数

解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。

2.开始编译

上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。

3.确定入口

从配置的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

4.编译模块

递归中根据文件类型loader 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

5.完成模块编译并输出

递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk

6.输出完成

输出所有的 chunk 到文件系统。


注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩覆盖之前的结果

4.2 代码实现

手写 Webpack 需要实现以下三个核心方法:

  • createAssets : 收集和处理文件的代码;
  • createGraph :根据入口文件,返回所有文件依赖图;
  • bundle : 根据依赖图整个代码并输出;

1. createAssets

function createAssets(filename){
    const content = fs.readFileSync(filename, "utf-8"); // 根据文件名读取文件内容
  
      // 将读取到的代码内容,转换为 AST
    const ast = parser.parse(content, {
        sourceType: "module" // 指定源码类型
    })
    const dependencies = []; // 用于收集文件依赖的路径

      // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径
    traverse(ast, {
        ImportDeclaration: ({node}) => {
            dependencies.push(node.source.value);
        }
    });

      // 通过 AST 将 ES6 代码转换成 ES5 代码
    const { code } = babel.transformFromAstSync(ast, null, {
        presets: ["@babel/preset-env"]
    });

    let id = moduleId++;
    return {
        id,
        filename,
        code,
        dependencies
    }
}

2. createGraph

function createGraph(entry) {
    const mainAsset = createAssets(entry); // 获取入口文件下的内容
    const queue = [mainAsset];
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);
        asset.mapping = {};
        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径
            const child = createAssets(absolutePath);
            asset.mapping[relativePath] = child.id;
            queue.push(child); // 递归去遍历所有子节点的文件
        })
    }
    return queue;
}

3. bunlde

function bundle(graph) {
    let modules = "";
    graph.forEach(item => {
        modules += `
            ${item.id}: [
                function (require, module, exports){
                    ${item.code}
                },
                ${JSON.stringify(item.mapping)}
            ],
        `
    })
    return `
        (function(modules){
            function require(id){
                const [fn, mapping] = modules[id];
                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports: {}
                }

                fn(localRequire, module, module.exports);

                return module.exports;
            }
            require(0);
        })({${modules}})
    `
}

五、总结

本文从编译器概念和基本工作流程开始介绍,然后通过 The Super Tiny Compiler 译器源码,详细介绍核心工作流程实现,包括词法分析器语法分析器遍历器转换器的基本实现,最后通过代码生成器,将各个阶段代码结合起来,实现了这个号称可能是有史以来最小的编译器。
本文也简要介绍了手写 Webpack 的实现,需要读者自行完善和深入哟!
是不是觉得很神奇~



当然通过本文学习,也仅仅是编译器相关知识的边山一脚,要学的知识还有非常多,不过好的开头,更能促进我们学习动力。加油!


最后,文中介绍到的代码,我存放在 Github 上:

1.【learning】the-super-tiny-compiler.js
2.【writing】webpack-compiler.js

六、参考资料

1.《The Super Tiny Compiler》
2.《有史以来最小的编译器源码解析》
3.《Angular 2 JIT vs AOT》

关于我

Author王平安
E-mailpingan8787@qq.com
博 客www.pingan8787.com
微 信pingan8787
每日文章推荐https://github.com/pingan8787...
ES小册js.pingan8787.com

查看原文

赞 20 收藏 11 评论 2

savokiss 发布了文章 · 3月30日

浅析 FP:JavaScript 中的纯函数

前言

纯函数 是一个常见的概念,在日常工作中也经常会遇到,它其实非常简单,今天我们来了解一下它的好处以及为什么要使用它。

两个特点

一个函数,如果符合以下两个特点,那么它就可以称之为 纯函数

  1. 对于相同的输入,永远得到相同的输出
  2. 没有任何可观察到的副作用

相同输入得到相同输出

我们先来看一个不纯的反面典型:

let greeting = 'Hello'

function greet (name) {
  return greeting + ' ' + name
}

console.log(greet('World')) // Hello World

上面的代码中,greet('World'),是不是永远返回 Hello World ? 显然不是,假如我们修改 greeting 的值,就会影响 greet 函数的输出。即函数 greet 其实是 依赖外部状态 的。

那我们做以下修改:

function greet (greeting, name) {
  return greeting + ' ' + name
}

console.log(greet('Hi', 'Savo')) // Hi Savo

greeting 参数也传入,这样对于任何输入参数,都有与之对应的唯一的输出参数了,该函数就符合了第一个特点。

没有副作用

副作用的意思是,这个函数的运行,不会修改外部的状态

下面再看反面典型:

const user = {
  username: 'savokiss'
}

let isValid = false

function validate (user) {
  if (user.username.length > 4) {
    isValid = true
  }
}

可见,执行函数的时候会修改到 isValid 的值(注意:如果你的函数没有任何返回值,那么它很可能就具有副作用!)

那么我们如何移除这个副作用呢?其实不需要修改外部的 isValid 变量,我们只需要在函数中将验证的结果 return 出来:

const user = {
  username: 'savokiss'
}

function validate (user) {
  return user.username.length > 4;
}

const isValid = validate(user)

这样 validate 函数就不会修改任何外部的状态了~

为什么要用纯函数?

你可能听过 纯函数 有不少优点,如果你经手过各种难维护的函数,你就更应该考虑使用 纯函数

可测试性(Testable)

让我们先用不纯的 greet 方法来做单元测试:

// jest 语法
describe('greet', function() {
  it('shows a greeting', function() {
    expect(greet('Savo')).toEqual('Hello Savo')
  });
});

如果我们修改了 greeting 变量为 Hi,上面的测试就会失败了,这本质上不应该发生。

那我们如果换成纯函数版本的 greet ,所有都是那么自然~ 只需要修改单元测试中传入的参数即可!

可缓存性(Cacheable)

纯函数可以根据输入来做缓存。实现缓存的是一种叫作 memorize 的技术。

下面的代码来自 Vue 源码:

/**
 * Create a cached version of a pure function.
 * 只适用于缓存 接收一个字符串为参数的 fn
 */
export function cached (fn) {
  const cache = Object.create(null)
  return function cachedFn (str) {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }
}

/**
 * Capitalize a string.
 */
export const capitalize = cached((str) => {
  return str.charAt(0).toUpperCase() + str.slice(1)
})

capitalize 即为缓存后的函数,如果多次调用就会返回缓存后的值,从而节省计算资源,而这一切的前提都建立在传入 cached 中的那个函数为纯函数的基础上。

可移植性 / 自文档化(Portable / Self-Documenting)

由于纯函数是自给自足的,它需要的东西都在输入参数中已经声明,所以它可以任意移植到任何地方。

并且纯函数对于自己的依赖是 诚实的,这一点你看它的 形参 就知道啦~正所谓 形参起的好,注释不用搞~(双押!)纯函数就是这么个正直的小可爱~

总结

好啦,我们已经大概了解了纯函数,它对于我们写出良好代码有着重要的意义,同时也是函数式编程中的精髓。本来本篇是想单纯介绍纯函数的,但是想起来了柯里化 (curry) 也没有讲过,那么下次有机会就讲一讲柯里化吧~

We'll see!

参考链接


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 4 收藏 3 评论 1

savokiss 发布了文章 · 3月24日

浅析 JS 设计模式之:工厂模式

前言

上次我们介绍了单例模式,没看过的小伙伴可以看这个链接:

浅析 JS 设计模式之:单例模式

今天来说一说一种常见的设计模式:工厂模式。

工厂模式是一种创建对象的 创建型模式,遵循 DRY(Don't Repeat Yourself)原则。在该模式下,代码将会根据具体的输入或其他既定规则,自行决定创建哪种类型的对象。简单点儿说就是,动态返回需要的实例对象

回顾上次的例子

让我们继续使用单例模式中的例子,一个日志工具 Logger :

class Logger {
    log (...args) {
        console.log(...args);
    }
}

上面是最核心的 api,每次使用都需要使用 new Logger() 来创建一个 logger 对象,然后使用方法就和 console 一样啦~

多种 Logger

假如我们现在的代码要支持 electron 环境,即日志既可以是 console 日志,也可以是 file 日志,那么我们就需要有两种类型的 logger:

ConsoleLogger

// logger/console.js
class ConsoleLogger {
    log (...args) {
        console.log(...args)
    }
}
export default ConsoleLogger

FileLogger

// logger/file.js
class FileLogger {
    log (...args) {
        dumpLog(...args)
    }
}
export default FileLogger

这里先不用管 dumpLog 的具体实现,只用知道它就是将日志写在文件中的即可~

使用工厂

我们已经有了两种类型的 logger,但是这两种 logger 的 api 实际上都是一样的,在项目中直接导入当然也可以使用,只不过每次都要导入对应类型的模块,然后再使用,像下面这样:

使用 console logger

import ConsoleLogger from './logger/console'
const logger = new ConsoleLogger()

使用 file logger

import FileLogger from './logger/file'
const logger = new FileLogger()

是不是很繁琐?如果还有其他 logger 类型,如远程日志,就会出现更多种使用方式了。为了把 logger 模块的使用方式统一,这时候就会用到工厂模式啦~

让我们新建一个 index.js

// logger/index.js
import ConsoleLogger from './console.js'
import FileLogger from './file.js'

function createLogger(type = 'console') {
    if (type === 'console') {
        return new ConsoleLogger()
    } else if (type === 'file') {
        return new FileLogger()
    }
    throw new Error(`Logger type not found: ${type}`)
}

export default createLogger

好了,这下我们的使用方式就会变成这样:

import createLogger from './logger'
// console logger
const logger1 = createLogger('console')
// file logger
const logger2 = createLogger('file')

重构一下

上面的 if else 不是很优雅?如果有更多中 logger 类型添加起来很麻烦?那我们可以使用对象来映射一下,从而抛弃 if else,同时添加一个 logger 选项。

// logger/index.js
import ConsoleLogger from './console.js'
import FileLogger from './file.js'

const loggerMap = {
    console: ConsoleLogger,
    file: FileLogger
}
// 可选参数一般放在最后面
function createLogger(options, type = 'console') {
    const Logger = loggerMap[type]
    if (Logger) {
        return new Logger(options)
    }
    throw new Error(`Logger type not found: ${type}`)
}
上面这种封装的方式,其实也符合 SOLID 原则中的 开闭原则,即 对扩展开放,对修改关闭,每当我们添加一种 logger 类型时,只需要新增一个文件,然后将构造器注册进 loggerMap 中即可。而外面的使用方式都是不变的,这样就用最少的修改完成了功能的新增,是不是很棒呀~

总结

下面我们来回顾一下工厂模式的优点:

  • 动态创建对象:可以用于需要在 运行时 确定对象类型的情况。
  • 抽象:封装了对象创建的细节,用户不会接触到对象的构造器,只需要告诉工厂需要哪种对象。
  • 可用性 / 可维护性:将相似的对象用一个工厂管理,提供统一的创建接口,满足 开闭原则,使我们可以轻松添加多种类型的对象,而无需修改大量代码。

好啦~!工厂模式就介绍到这里啦~ 下次我们讲一讲装饰器模式~

参考内容


本文首发于公众号:码力全开(codingonfire)

本文随意转载哈,注明原文链接即可,公号文章转载联系我开白名单就好~

codingonfire.jpg

查看原文

赞 7 收藏 3 评论 5