前言
最近部门新开了一个业务线,需要一个内部的开发平台,前端部分是由我来负责的。正好借着这个机会记录一下整个项目架构开发部署的过程,以便查漏补缺。
项目目标:维护内部日常运营数据,财务信息,各种产品的对接信息等。对稳定性要求较高,故而选用较为保守的技术栈。
技术栈:
- React
17 - react-router-dom
5.2 - momentjs
2.29.1 - antd
4.17.3 - @antv/g2plot
2.3.13 - axios
0.20.0 - react-scripts
3.4.3 - ssf
0.11.2 - http-proxy-middleware
1.0.6
初始化项目
使用 creat-react-app 创建
npx create-react-app my-app
创建过程跳过,直接看项目最终目录结构:
接下来一点一点介绍。
入口
index.js文件使用自动生成的即可,我们在App.js中做一些手脚:
import React from 'react';
import { HashRouter, Route, Switch } from "react-router-dom";
import { ConfigProvider } from 'antd'
import moment from 'moment';
// 兼容antd和moment的国际化
import zhCN from 'antd/es/locale/zh_CN';
import 'moment/locale/zh-cn';
// 布局组件
import BasicLayout from "./layouts";
// 登录组件
import Login from "./pages/Login";
import './App.css';
import './index.css';
function App() {
// 注册moment国际化 - 中文默认
moment.locale('zh-cn');
return (
// antd配置
<ConfigProvider locale={zhCN}>
<HashRouter>
<Switch>
<Route path="/login" component={Login} />
<Route path="/" component={BasicLayout} />
</Switch>
</HashRouter>
</ConfigProvider>
);
}
export default App;
可以看到我把国际化注册为中文了,因为是内部系统,不需要处理英文的情况。在路由配置中,默认根路由会进入布局页面,在布局组件中,会构建左侧目录树,此时会获取登录状态:
// BasicLayout
...
function genRoute(menu) {
if (!menu.component) return null
return <AuthRoute path={menu.key} component={menu.component} key={menu.key} refreshStore={menu.refreshStore} />
}
在自定义的AuthRoute中来判断是否登录,若没有登陆,则跳转到登录界面:
// AuthRoute
return (
<Route
{...rest}
render={props => {
// isLogged 可从localStorage中获取
return isLogged ? <Component {...props} /> : <Redirect to="/login" />
}}
/>
)
再来看看登录页面怎么写的。在登录时,通过接口拿当前的用户,若拿不到则抛出用户不存在;如存在,写入localStorage,并跳转。登录信息的录入可以通过.env配置拿到,也可以通过前端表单输入拿到,这里不做过多展示。
...
function Login() {
useEffect(() => {
getCurrentUser();
// eslint-disable-next-line
}, []);
const getCurrentUser = () => {
setLoading(true);
// API 调用
GetUserInfo().then(res => {
setLoading(false);
if (res.RetCode === 0) {
// 写入session 和 local 的函数
setUserSession(res.Data);
setSystemStore();
// 跳转布局组件
window.location = `/#/`;
} else {
setError(res.RetCode || true);
}
}, e => {
setError(e.message);
setLoading(false);
});
}
}
...
API请求
我们对axios封装一下,放在apis文件夹下。
使用axios拦截器:
const createService = baseURL => {
...
if (process.env.REACT_APP_IS_ONLINE !== 'true') {
// request拦截器
service.interceptors.request.use(
config => {
config.headers['remote_user'] = remoteUser ? remoteUser : '';
return config;
},
...
);
}
}
然后拿到处理后的axios实例:
export const baseFetch = createService(process.env.REACT_APP_API_BACKEND);
至于这些全局变量,可以放在.env中:
...
REACT_APP_IS_ONLINE = false
REACT_APP_REMOTE_USER = xiaodududududuo
REACT_APP_API_BACKEND = /apis
REACT_APP_API_BACKEND_URL = http://192.168.122.21:8009
...
可以看到,REACT_APP_API_BACKEND 和 REACT_APP_API_BACKEND_URL 是不一样的,这里使用 setupProxy在代理一下:
const { createProxyMiddleware } = require('http-proxy-middleware');
const options = [
{
name: process.env.REACT_APP_API_BACKEND,
target: process.env.REACT_APP_API_BACKEND_URL
},
]
module.exports = function (app) {
options.forEach(item => {
const pathRewrite = {};
pathRewrite[item.name] = "";
app.use(createProxyMiddleware(item.name,
{
target: item.target,
pathRewrite,
changeOrigin: true,
}
));
})
};
这样绕一下,不直接使用REACT_APP_API_BACKEND_URL的意图是为了给API请求路径加上对应的后缀,按照上边这样的配置,请求路径就会是 localhost:3000/apis,而不是直接显示ip地址,同时也是为了下面nginx配置区分不同的请求。
关于env变量,可以在本地放置.env_pre 和 .env_prod,用户在打包部署时区分开发和生产环境。
转过头再看API的使用,我们写一个自己的fetch方法:
function fetchActions(data, fetchOptions) {
return baseFetch({
method: 'post', // 可以定制请求方式
data: { ...data }, // 这里可以定制参数
...fetchOptions // 可以方式 responseType 等定制化字段
}).then(res => {
if (res.RetCode !== 0) {
const errorMessage = res.Message + res.Error || res.RetCode || "error";
message.error(errorMessage);
}
return res
})
}
由于我们后端定义的接口统一是post的,用一个字段Action来区分,所以我们可以这样定义请求了:
export const GetUserInfo = (params) => fetchActions({ Action: "GetUserInfo", ...params });
权限系统
平台先配置需要权限管理的资源,每一个用户可配置自己的角色,每一个角色配置相应的权限资源列表即可:
配置资源
建立一个资源配置管理的界面:
所有的界面、弹窗、按钮都可以放在这里边。
接着建立人员库:
每一个人员都配有自己的一组角色。
接着对每一个角色进行资源权限配置:
如此,便做到了权限配置。
在业务侧如何做到显示隐藏呢。在配置完权限后,刷新缓存,可通过接口拿到当前登录用户的所有权限:
在代码上,只需要写一个函数判断列表里有没有就可以了:
export function keyHasPermission(key, permissions = []) {
return permissions.findIndex(item => item.resource === key) > -1;
}
布局
由于这个平台是适用于多个产品形态的,多个产品公用一套menu,所以在layout中需要有切换产品的地方:
在切换产品的时候,我设置清除本地localStorage,并写入切换产品的数据。在menu展示的时候,也可通过权限来判断是否显示:
function genMenus(menus) {
return menus.reduce((prev, next) => {
return prev.concat(
hasChild(next)
? menuHasPermission(next, permissionList) && genSubMenu(next)
: menuHasPermission(next, permissionList) && genMenItem(next)
)
}, [])
}
...
// 校验菜单权限
export function menuHasPermission(menu, permissions = []) {
// 菜单设置aclKey,则使用acl获取权限,否则所有人都有权限
if (menu.aclKey) {
if (permissions) {
return permissions.findIndex(item => item.resource === menu.aclKey) > -1;
} else {
return false
}
}
return true
}
如此,产品架构便是搭好了!
业务封装
封装的公共组件如下:
常用的工具也做了整合:
由于这次篇幅已经较长,具体业务组件的封装,我会记录在本专栏后面的文章里。
部署
部署分为预发环境和线上环境两种。预发是给测试同学用的,线上是给真正的用户使用的。
在scripts下放置一个build.sh:
#!/usr/bin/env bash
env=$1
function help()
{
echo " please load this file to the host you will run uconf"
echo " build.sh env"
echo " env : "pre" or "prod""
}
if [ $# -ne 1 ];then
help
exit 1
fi
echo "set dev: " $env
path=$(pwd)
echo $path
if [ $env = "pre" ];then
cp .env_pre .env
elif [ $env = "prod" ];then
cp .env_prod .env
else
help
exit 1
fi
# yarn打包
echo "start compiling..."
yarn build
cp .env_pre .env
echo "build finish"
# 打包docker镜像
version=`cat VERSION`
time=$(date "+%Y%m%d%H%M%S")
tag="$version""_""$time"
app_name=工程名"_""$env"
default_registry=公司的docker仓库
default_project=你的项目名称
default_user=默认用户
default_pwd=密码
dockerfile=Dockerfile
nginx_conf="./scripts/nginx_""$env"".conf"
echo $(date "+%Y-%m-%d %H:%M:%S")-开始制作镜像
# amd64可以直接打包,arm64用第二条命令打包
# docker build --rm --build-arg NGINX_CONF=$nginx_conf -t $app_name:latest .
docker build --platform linux/amd64 --rm --build-arg NGINX_CONF=$nginx_conf -t $app_name:latest .
echo $(date "+%Y-%m-%d %H:%M:%S")-镜像制作完成
echo $(date "+%Y-%m-%d %H:%M:%S")-开始打tag
docker tag $app_name:latest $default_registry/$default_project/$app_name\:$tag
echo $(date "+%Y-%m-%d %H:%M:%S")-打tag完成
echo $(date "+%Y-%m-%d %H:%M:%S")-开始push镜像
docker login -u $default_user -p $default_pwd $default_registry
docker push $default_registry/$default_project/$app_name\:$tag
echo $(date "+%Y-%m-%d %H:%M:%S")-push镜像完成
echo $(date "+%Y-%m-%d %H:%M:%S")-删除本地镜像
docker rmi $app_name\:latest
docker rmi $default_registry/$default_project/$app_name\:$tag
echo "执行发布脚本请输入tag:"
echo $tag
该文件会根据执行时传入的参数(pre或prod)来动态读取不同的配置问件,并本地build,最后会推送到本地docker镜像,最后会输出镜像的tag。你可以用这个tag来在远程服务器上部署。
上面文件中提到了nginx配置。这里说明一下:
项目里放置两种环境的nginx配置文件:
upstream test {
server 映射的地址;
}
server {
listen 80;
server_name localhost;
underscores_in_headers on;
location / {
try_files /index.html =404;
root /data/项目名/build;
}
location ~ .*\.(js|gif|png|map|jpg|css|jpegcss|swf|ico|txt|html|less|jar|tpl|tgz|woff|tff)$ {
root /data/项目名/build;
proxy_redirect off;
expires 30d;
error_page 405 =200 http://$host$request_uri;
}
location /test {
proxy_pass http://test/;
proxy_set_header Host $Host;
}
...
}
会根据不同的配置路径映射到不同的url上。
有了上述配置,只需执行指令即可完成打包:
sh build.sh pre
最后部署时,可以在服务器对应部署文件夹内新建一个脚本:
// deploy.sh
#!/bin/bash
if [ ! -n "$1" ]
then
echo "You should give me a image tag."
else
echo "The image tag is $1"
oldtag=`cat .env`
echo "The old image tag is $oldtag"
path=$(pwd)
# 对匹配到的路径字符进行替换:sed -i 's/原字符串/新字符串/' ab.txt
sed -i "s/$oldtag/TAG=$1/g" $path/.env
docker login -u 工程名 -p 密码 docker的hub
# docker自动化
docker-compose up -d
fi
然后执行sh deploy.sh [tag名称]即可完成部署!