OAuth统一登录服务落地中前端开发BFF总结

1,726 阅读12分钟

前景

在21年第四季度的时候,公司中台部门实现了OAuth登录服务,实现了B/C端用户信息大一统,需要在B/C端各个业务组需要统一接入,B端中本人的组和其他两个组(指挥人才组、人才推荐组)同步接入,为了方便业务组接入,承担了业务组件库skylineUI和基于egg.js中转层的开发。 应用此组件相关网站:

任务计划分为前后两期,计划如下:

  • 前期包含B端各组后端开发提交原有各组用户数据,由中台部门统一进行用户清洗,保证用户数据统一和唯一,前端同步开发npm包以及基于egg.js的中转层(中台开放API比较原子化,需要进行统一排列组合);
  • 后期需要各个业务组使用相关插件,并根据各组具体业务流程完善接入之后的逻辑,并按照各组完成排期交给测试部门并上线

难点

前端层面难点:

  • B端前端项目如何在开发人员紧张的情况下,保证各个业务组自身功能迭代不受影响下,使用高性价比的方法,保证UI统一,各个流程统一,且不影响引用的项目业务和页面。比如受限于时间原因和技术选项不同等原因,React版本不同(有的是V16.8,有点组低于16.8), UI库antd版本不同(有的是V4.x, 有的是V3.18.x,有的UI库不是antd), 脚手架不同(有的是umi3,有的是create-react-app)
  • 各个组别前端项目的定义标准不同。比如三个业务组的区分开发/生产环境标准不同(有的可以基于process变量不同区分,有的做不到),运维程度不同(有的可以jenkins自动化部署,有的基于shell脚本半自动部署)

中转层难点:

  • 中台部提供了原子化的API,但是需要一个中转层按照业务,进行排列组合成为一个API,供给组件库使用
  • 在后端资源紧张的情况下,中转层需要由前端实现,同时要保证稳定性

方案

前端层方案:

在前端开发人员资源缺少的情况下,果断放弃各个业务组的前端开发人员按照同一个设计图开发不同业务弹窗来实现相同主流程,比如一个登录弹窗三个项目组三个前端留个项目写六份,不但重复劳动耗时巨大而且没法统一管理。

最后决定开发一个业务组件库,该组件库以antdV4.16.13为主,按照设计图,统一封装,登录、注册、绑定邮箱、绑定商户、绑定电话、找回密码、设置密码等业务弹窗,开发一个npm包,放在公司内部npm仓库中,由各个业务组统一安装使用,按照语义化版本进行迭代,同时根据第二个难点,决定版本号-beta版本作为开发/测试期间的版本,上线部署统一按照@latest的正式包。为了方便各个组前端开发使用,需要一定文档说明和example,最后选择阿里的dumi作为文档和代码样例的展示。

中转层方案:

中转层最大的任务就是按照中台对外提供OAuth服务的接口,按照业务进行排列组合二次封装,并根据业务场景给出给为标准友好的处理。而且需要做性能监控、日志处理、自动化部署等处理,需要周边生态比较健全。最后选择了egg.js作为中转层实现,相比于koa2或者express,egg在阿里内部使用多年,同时周边插件丰富(也可以用koa2的插件,毕竟是基于koa2封装的),用ali-node作为性能监控,egg-plugin-logger作为日志处理。同时配合运维自动化部署的要求,用docker + jenkins实现,需要为运维提供一份dockerfile。

实现

前端实现

针对难点一

React版本层面,经过我们分析,我们可以将React的最低版本固定在V16.12上,在此版本上,我们既可以兼容旧版本的Class Component写法,又可以使用Hooks的写法,这算是一种无痛升级。所以在开发业务组件库中,特地在package.json的peerDependencies字段中设置react和react-dom的最低版本。如果install此包,不满足peerDependencies的版本,会抛出error告知开发者,让各组开发者自己升级项目依赖的react版本。

skylineUI_1.png

UI-antd版本层面,经过我们分析,V4.x和V3.x在css层面差异较多,V4.x的样式拆分更细,且命名有不少改动。而且V4.x和V3.x在Form组件的实现上和暴露属性上差异更大,如果强行升级到v4.x版本,会让开发人员投入大量经历修改老的业务代码和流程,测试人员需要重新测试,花费巨大。而且在实际验证的时候发现,如果引用包的项目也存在antd同名包的情况下,组件库在import的时候会优先使用引用项目的package.json的命名为antd的版本的包,而不是更深层的自己package。json中dependencies的命名为antd的版本的包。所以我们选择一种取巧的方法,我们在业务组件库中的antd包重新起了别名为antd4,就可以避免这种情况,但是在引入包和配置按需加载的时候,就需要变成以下写法。同时修改antd中样式的前缀,修改为skyline,避免样式上互相覆盖,

// import { Modal } from 'antd'; 此种写法会与引用包的项目的antd包冲突
import { Modal } from 'antd4';

skylineUI_002.png

skylineUI_003.png

针对难点二

安装了cross-env设置process.env.BUILD_ENV变量为dev和prod来进行区分,配合package.json中scripts不同配置来实现环境区分,需要注意rollup打包的时候,需要结合rollup-plugin-replace这个插件一起设置,不然无法获取环境变量,-beta版本的包请求测试环境接口,无beta版本号请求正式环境接口

skylineUI_004.png

项目结构

.
├── README.md
├── docs // dumi中使用site站点模式下,书写开发文档和代码样例的地方
│   ├── Modal
│   └── index.md
├── package.json
├── public
│   └── static
├── rollup-config.js
├── src
│   ├── Modal // 各种业务组件,因为作者本人属于B端,所以business文件下是B端组件开发
│   │   ├── base // 基于antd的Modal,按照设计图封装的基础组件
│   │   │   ├── index.tsx
│   │   │   └── styles.less
│   │   ├── business
│   │   │   ├── bindEmail // 绑定邮箱
│   │   │   ├── bindOrgInfo // 绑定商户
│   │   │   ├── bindPhone // 绑定电话
│   │   │   ├── connectBussiness // 联系我们
│   │   │   ├── index.ts
│   │   │   ├── login // 登录弹窗,邮箱,电话-密码登录,电话短信登录,和微信扫码登录
│   │   │   ├── register // 注册弹窗
│   │   │   └── settingPwd // 设置/修改密码
│   ├── assets
│   ├── components // 封装公共组件
│   ├── config // 公共的配置
│   ├── data
│   ├── index.ts
│   ├── locales // 中英文国际化,在后期已实现
│   ├── propType
│   ├── service // 业务请求
│   ├── style // 覆盖antd样式,按照设计图修改部门样式
│   └── utils
├── tsconfig.json
└── typings.d.ts

在开发中遇到的问题,所有的弹窗都是相对于独立的情况,但是唯独登录业务弹窗是相对不同,在tab切换和拉起注册弹窗等其他业务弹窗的时候,需要保证流畅的动画效果和不突兀的流程,所以几个相关业务弹窗的状态由自身处理,只对外暴露一些特定方法,比如onReigsterSuccess、onReigsterFailed、onLoginSucess、onLoginFailed等方法,让开发者插入自己的业务逻辑.而且有的B端项目并不需要短信的验证码登录的方式,所以tabConfigList是可配置的方式

import React, { useEffect, useState } from 'react';
// 统一的form表单的PropType
import { CommonLoginFormPropType } from '@/propType/form/loginForm';
import CustomTab from '@/components/tab';
import { FormattedMessage } from '@/locales';
import BaseModal from '../../base';

import styles from './styles.less';

// 自定义封装短信登录部分
import {
  SmsLoginForm,
  OtherAuthGroup,
  PwdLoginForm,
} from '../../component/form';

// 自定义注册Modal
import { RegisterAccountContent } from '../register';
import { RegisterAccountContent as RegisterAccountCustomerContent } from '../../business/register';

import { SettingPwdContent } from '../settingPwd';

const defaultTabConfigList = [
  {
    title: (
      <FormattedMessage id="common.sms.login.tab" defaultMessage="SMS login" />
    ),
    value: 'sms',
  },
  {
    title: (
        <FormattedMessage id="common.account.login.tab" defaultMessage="Account login" />
    ),
    value: 'account',
  },
];

interface PropTypes {
  visible: boolean;
  type: ['customer', 'business'];
  registerType: ['customer', 'business'];
  isShowRegisterAccount: boolean;
}

const LoginModal = (props: PropTypes) => {
  const {
    visible,
    registerType,
    type,
    isShowRegisterAccount,
    tabConfigList,
    projectType,
  } = props;

  const [isShowModal, setIsShowModal] = useState(visible || false);
  const [isShowRegisterModal, setIsShowRegisterModal] = useState(false);
  const [selectedTabValue, setSelectedTabValue] = useState('sms');
  const [isShowResetPwdModal, setIsShowResetPwdModal] = useState(false);
  const [resetPwdModalType, setResetPwdModalType] = useState('phone');
  const [registerModalTabValue, setRegisterModalTabValue] = useState('phone');

  useEffect(() => {
    setIsShowModal(props.visible);
  }, [props.visible]);

  const handleLoginSuccess = (res) => {
    setIsShowRegisterModal(false);
    setIsShowResetPwdModal(false);
    if (props?.onSuccess) {
      props.onSuccess(res);
    }
  };

  const handleLoginFailed = (errInfo) => {
    if (props?.onFailed) {
      props.onFailed(errInfo);
    }
  };

  const handleCloseClick = () => {
    let modalType = '';
    if (isShowModal) {
      modalType = 'login';
    } else if (isShowRegisterModal) {
      modalType = 'register';
    } else {
      modalType = 'resetPwd';
    }
    switch (modalType) {
      case 'login':
        setIsShowModal(false);
        break;
      case 'register':
        setIsShowRegisterModal(false);
        break;
      case 'resetPwd':
        setIsShowResetPwdModal(false);
        break;
      default:
    }
    if (props?.onCancel) {
      props.onCancel(modalType);
    }
  };

  const commonFormProps: CommonLoginFormPropType = {
    isShowMsg: true,
    onBeforeSubmit: props?.onBeforeSubmit ? props?.onBeforeSubmit : () => true,
    onSuccess: handleLoginSuccess,
    onFailed: handleLoginFailed,
  };

  const handleRegisterClick = (value) => {
    setRegisterModalTabValue(value);
    setIsShowRegisterModal(true);
    setIsShowModal(false);
    if (props?.onRegisterClick) {
      props.onRegisterClick();
    }
  };

  const handleResetPwdClick = (modalType) => {
    setResetPwdModalType(modalType);
    setIsShowResetPwdModal(true);
    setIsShowModal(false);
    if (props?.onResetPwdClick) {
      props.onResetPwdClick();
    }
  };

  const renderResetPwdModalContent = () => {
    return (
      <SettingPwdContent
        onCancel={handleCloseClick}
        type={resetPwdModalType}
        isReset={true}
        {...commonFormProps}
        projectType={projectType}
        onSuccess={handleSettingPwdModalSuccess}
      />
    );
  };

  const renderRegisterModalContent = () => {
    switch (registerType) {
      case 'customer':
        return <RegisterAccountCustomerContent {...commonFormProps} />;
      case 'business':
        return (
          <RegisterAccountContent
            isShowGoLogin={true}
            onGoLoginClick={() => {
              setIsShowRegisterModal(false);
              setIsShowModal(true);
            }}
            projectType={projectType}
            {...commonFormProps}
          />
        );
      default:
        return null;
    }
  };

  const handleSettingPwdModalSuccess = (values) => {
    setIsShowResetPwdModal(false);
    setIsShowModal(true);
  };

  const renderSimpleHeaderGroup = () => {
    const title = tabConfigList[0]?.title;
    return (
      <div className={styles.simpleHeaderGroup}>
        <div>{title}</div>
      </div>
    );
  };

  const renderLoginModalContent = () => {
    if (tabConfigList?.length === 1) {
      return (
        <div className={styles.loginContentGroup}>
          {renderSimpleHeaderGroup()}
          {tabConfigList[0]?.value === 'sms' ? (
            <SmsLoginForm
              {...commonFormProps}
              projectType={projectType}
              isShowResetPwdAccount={false}
              isShowRegisterAccount={isShowRegisterAccount}
              onRegisterClick={handleRegisterClick}
              onResetPwdClick={handleResetPwdClick}
            />
          ) : (
            <PwdLoginForm
              {...commonFormProps}
              projectType={projectType}
              isShowRegisterAccount={isShowRegisterAccount}
              onRegisterClick={handleRegisterClick}
              onResetPwdClick={handleResetPwdClick}
            />
          )}
        </div>
      );
    } else if (tabConfigList?.length > 1) {
      return (
        <div className={styles.loginContentGroup}>
          <CustomTab
            projectType={projectType}
            align={'center'}
            tabConfigList={tabConfigList}
            onChange={(value) => {
              setSelectedTabValue(value);
            }}
          >
            {tabConfigList.map((tabConfigItem, index) => {
              return (
                <div key={index}>
                  {tabConfigItem?.value === 'sms' ? (
                    <SmsLoginForm
                      {...commonFormProps}
                      projectType={projectType}
                      isShowResetPwdAccount={false}
                      isShowRegisterAccount={isShowRegisterAccount}
                      onRegisterClick={handleRegisterClick}
                      onResetPwdClick={handleResetPwdClick}
                    />
                  ) : (
                    <PwdLoginForm
                      {...commonFormProps}
                      projectType={projectType}
                      isShowRegisterAccount={isShowRegisterAccount}
                      onRegisterClick={handleRegisterClick}
                      onResetPwdClick={handleResetPwdClick}
                    />
                  )}
                </div>
              );
            })}
          </CustomTab>
        </div>
      );
    } else {
      return <div className={styles.loginContentGroup}></div>;
    }
  };

  return (
    <React.Fragment>
      <BaseModal
        {...props}
        onCancel={handleCloseClick}
        visible={isShowModal || isShowRegisterModal || isShowResetPwdModal}
      >
        {/* 登录表单部分 */}
        {isShowModal && renderLoginModalContent()}
        {/* 注册表单部分 */}
        {isShowRegisterModal && renderRegisterModalContent()}
        {isShowResetPwdModal && renderResetPwdModalContent()}
      </BaseModal>
    </React.Fragment>
  );
};

LoginModal.defaultProps = {
  visible: false,
  type: 'business',
  projectType: '',
  registerType: 'client',
  isShowRegisterAccount: true,
  tabConfigList: defaultTabConfigList,
};

export default LoginModal;

skylineUI_006.png

import React, { useState } from 'react';
import { Button } from 'antd4';
import { ModalBusiness } from 'skyline_ui';
import 'skyline_ui/src/theme/default.less';

export default () => {
  const [isShowLoginModalBusiness, setIsShowLoginModalBusiness] =
    useState(false);
  return (
    <div>
      <Button
        style={{ margin: '20px' }}
        onClick={() => {
          setIsShowLoginModalBusiness(true);
        }}
      >
        登录弹窗(B端) - 普通
      </Button>

      <ModalBusiness.LoginModal
        centered={true}
        visible={isShowLoginModalBusiness}
        type={'business'}
        isShowRegisterAccount={false}
        registerType={'business'}
        onCancel={() => {
          setIsShowLoginModalBusiness(false);
        }}
        onBeforeSubmit={(res) => {
          return true;
        }}
        onSuccess={(res) => {
          console.log('----- onSuccess res = ', res);
        }}
        onFailed={(err) => {
          console.log('----- onFailed res = ', err);
        }}
      />
    </div>
  );
};

开发调试和构建

开发层面, dumi整合了umi3的框架,在dev模式下,会根据根目录.umirc的配置文件来启动项目,我们选择site站点模式,在build之后需要将开发文档构建成为静态HTML页面,部署在服务器上,让其他开发人员方便查看。

版本迭代层面,主要修改package.json中的version字段来进行控制,同时npm提供了命令来自动管理包的版本。只需要根据当前修改的范围来自行选择运行以下命令即可

"npm version patch/major/minor

构建方面,dumi默认使用了阿里整合rollup的构建工具father.js,提供了可配置的构建选项,只需要在根目录下配置fatherrc配置文件即可,相比于自己写rollup.config的配置更方便性价比更高,所以选择了father.js来构建

调试层面, 在build之后,需要在项目中引用组件库,查看实际构建效果,可以使用link命令来链接到本地开发中的业务组件库

npm link skyline_ui

后端实现

针对难点一

因为中台提供服务,根据前后端不同场景,考虑灵活性,所以设计的时候有意粒度很小功能单一的API。比如一个邮箱注册逻辑,需要组合的API如下所示:

  1. 查询当前邮箱是否注册过,如果已注册,给予提示;没有注册进入步骤2
  2. 根据注册表单中部分信息注册账号
  3. 根据注册表单中的部分信息和以注册好的账户ID,进行更新用户信息操作
  4. 根据传递参数,判断是否需要进行登录操作,如果需要登录,返回accesss_token和refresh_token;如果不需要,则无返回,给予提示注册成功

当时后端开发人员紧缺,同时只是一个胶水层的方向,所以中转层也交给前端实现。egg.js在context上下文中内置了curl方法,是基于urllib实现了一个httpClient的操作,方便发起一个http请求,可以进行中转操作。为了方便调用,在此基础上做了一下简单封装。

import { proxyBaseUrl } from '../config';
import { getProxyAPIByName } from '../config/proxy.api.config';

import { compile } from 'path-to-regexp';

/**
 * 作用: 封装统一的代理转发的方法
 * @param ctx
 * @param apiKey
 * @param data
 * @param options
 */
export const proxyRequest = async (ctx, apiKey, data, options = {}) => {
  const defaultOptions = {
    dataType: 'json',
    contentType: 'json',
    headers: {},
    baseUrl: proxyBaseUrl,
  };
  const { toPathData = null, ...otherData } = data;
  const proxyRequestOptions = {...defaultOptions, ...options};
  let { method = 'GET', url = '' } = getProxyAPIByName(apiKey);

  if (toPathData) {
    const toPath = compile(url, { encode: encodeURIComponent });
    url = toPath(toPathData);
  }
  // 拼凑完整的转发地址
  const proxyRequestUrl = options?.baseUrl ? `${options.baseUrl}${url}` : `${proxyBaseUrl}${url}`;
  const result = await ctx.curl(proxyRequestUrl, {
    method,
    headers: proxyRequestOptions?.headers,
    contentType: proxyRequestOptions.contentType,
    data: otherData,
    dataType: proxyRequestOptions.dataType,
  });
  return result;
};

针对难点二

保证服务的稳定性,使用ali-node的性能监控和egg-plugin-logger来记录日志,配置了按小时切割,方便发现问题后定位异常日志。

实现docker+jenkins自动化部署,配合运维人员,写了一份简单的Dockerfile

FROM node:14-alpine

# 替换源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories

# 设置时区
RUN apk --update add tzdata \
    && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
    && echo "Asia/Shanghai" > /etc/timezone \
    && apk del tzdata

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

COPY package.json /usr/src/app/package.json

RUN npm i -D --registry=https://registry.npm.taobao.org

COPY . /usr/src/app

RUN npm run tsc

EXPOSE 7001

CMD npm run start

项目结构

.
├── Dockerfile
├── Jenkinsfile
├── README.md
├── app
│   ├── config
│   │   ├── index.ts // 配置不同环境下部署的地址,转发的地址,不同的项目ID等通用化配置
│   │   └── proxy.api.config.ts // 设置转发到中台的api
│   ├── controller
│   │   ├── auth.ts // 组合中转的业务逻辑
│   ├── helper
│   │   ├── errorMessage.ts // 统一错误提示
│   │   ├── phone.ts
│   │   └── proxyRequest.ts // 封装了ctx.curl方法做中间
│   └── router.ts // 后端路由配置
├── appveyor.yml
├── config
│   ├── config.default.ts
│   ├── config.local.ts
│   ├── config.prod.ts
│   └── plugin.ts
├── package.json
├── tsconfig.json
└── yarn.lock

不足

前端层面:

  • 移动端兼容性适配问题,在早期规划中并没有涉及,后期有了相关需求,只能使用媒体查询亡羊补牢的做一定处理,只在模型应用平台更新了一个版本
  • 没有考虑css修改的问题,build的时候css选择了hash处理,不利于其他项目拓展
  • 优化问题,只是保证了当时的设计场景,比如使用extrenal优化,dll优化等等,同时最近兴起一种规范就是build的时候保留source-map,方便引用包的时候出现问题能更好的追踪

中转层层面:

  • 没有更好的抽取service层
  • 受限于资源问题,没有做到异地部署和一定容灾处理

总结

很感谢这次开发,能够有机会落地egg.js和npm包,并让多个业务组使用。无论从技术选型到落地,还是从跨部门沟通能力都受到了锻炼,特此总结记录一篇。