本项目已经上传至github,感兴趣可以下载学习 experience-monitor
1. 概要
【技术选型】
- UI框架:React + Ant-Design5
- 数据库:MySQL + TypeORM
- 服务端:Node + Midway
- 语言:TypeScript
- 日志:Redis + Ioredis
- 任务调度:Bull
【业务介绍】
- 功能:前端性能监控平台
- 模块:
- 登录注册
- 权限管理
- 用户管理
- 菜单管理
- 角色管理
- 账号密码配置
- 系统运维
- 服务监控
- 任务调度
- 体验监控
- 错误上报
- 日志明细
- 监控看板
2. 准备工作
2.1 node
安装配置node 略
2.2 mysql
2.2.1 下载安装
cdn.mysql.com//Downloads/… 查看电脑是x86还是arm,以选择mysql的版本。
uname -a
下载后双击安装按步骤即可。
2.2.2 配置
sudo vim ~/.zshrc
输入
export PATH=$PATH:/usr/local/mysql/bin
生效
source ~/.zshrc
验证
mysql --version
使用
mysql -uroot -p
- -u:mysql用户名,这里直接将用户名 root 挨着写在后边
- -p:mysql密码,这里没有写会提示让输入,也可以直接挨着p写在后边 然后输入密码就可以使用了
这时从系统偏好中也可以看出服务已经启动了。
2.2.3 安装可视化工具
输入密码即可(我的是开机密码)
- 创建数据库
也可以执行命令创建:
CREATE SCHEMA `test` DEFAULT CHARACTER SET utf8 ;
- 创建表
也可以执行命令创建:
CREATE TABLE `test`.`table1` (
`id` INT NOT NULL,
`name` VARCHAR(45) NULL,
`password` VARCHAR(45) NULL,
`age` INT NULL,
PRIMARY KEY (`id`));
- 插入和查询数据
INSERT INTO `test`.`table1` (`id`, `name`, `password`, `age`) VALUES ('1', '小张', 'xiao_zhang', '100');
INSERT INTO `test`.`table1` (`id`, `name`, `password`, `age`) VALUES ('2', '小王', 'xiao_wang', '21');
2.3 redis
- 下载安装
brew install redis
redis服务默认是安装在 /usr/local/Cellar
目录下的,(Cellar字面意思是酒窖,地下室) 其配置文件redis.conf是在 /usr/local/etc
目录下的。
- 启动服务
brew services start redis
关闭、重启可以这样:
brew services stop redis
brew services restart redis
- 打开客户端界面
redis-cli
远程连接
redis-cli -h host -p 6379
- 操作下
2.4 midway
2.4.1 工程初始化
参考官方文档,直接执行初始化web项目:
npm init midway
启动项目
npm run dev
2.4.2 编写测试应用
简单看一个接口的示例:
controller/wheather.ts
import { Controller, Get, Query } from '@midwayjs/decorator';
@Controller('/')
export class WeatherController {
@Get('/weather')
async getWeatherInfo(@Query('city') city: string): Promise<string> {
return `It's raining in ${city}!`;
}
}
2.4.3 midway最小完整demo
先看下业务流程:
会实现几个部分包括:Controller、Service、View、Filter、Error等。
接口路由:src/controller/weather.ts
import { Controller, Get, Inject, Query } from '@midwayjs/decorator';
import { WeatherService } from '../service/weather';
import { Context } from '@midwayjs/koa';
@Controller('/')
export class WeatherController {
@Inject()
weatherService: WeatherService;
@Inject()
ctx: Context;
@Get('/weather')
async getWeatherInfo(@Query('cityId') cityId: string): Promise<void> {
const result = await this.weatherService.getWeather(cityId);
if (result) {
await this.ctx.render('info', result.weatherinfo);
}
}
}
这里会用到一个天气数据,所以定义一个接口:
src/interface.ts
/**
* @description 天气信息
*/
export interface WeatherInfo {
weatherinfo: {
city: string;
cityid: string;
temp: string;
WD: string;
WS: string;
SD: string;
AP: string;
njd: string;
WSE: string;
time: string;
sm: string;
isRadar: string;
Radar: string;
};
}
路由后会调用具体的业务处理服务。
业务处理:src/service/weather.ts
import { Provide } from '@midwayjs/decorator';
import { makeHttpRequest } from '@midwayjs/core';
import { WeatherInfo } from '../interface';
import { WeatherEmptyDataError } from '../error/weather';
@Provide()
export class WeatherService {
async getWeather(cityId: string): Promise<WeatherInfo> {
if (!cityId) {
throw new WeatherEmptyDataError();
}
try {
const result = await makeHttpRequest(
`http://www.weather.com.cn/data/cityinfo/${cityId}.html`,
{
dataType: 'json',
}
);
if (result.status === 200) {
return result.data;
}
} catch (error) {
throw new WeatherEmptyDataError(error);
}
}
}
这里会抛出错误,所以定义一个错误类
错误类:src/error/weather.ts
import { MidwayError } from '@midwayjs/core';
export class WeatherEmptyDataError extends MidwayError {
constructor(err?: Error) {
super('weather data is empty', {
cause: err,
});
if (err?.stack) {
this.stack = err.stack;
}
}
}
错误拦截:src/filter/weather.ts
import { Catch } from '@midwayjs/decorator';
import { Context } from '@midwayjs/koa';
import { WeatherEmptyDataError } from '../error/weather';
@Catch(WeatherEmptyDataError)
export class WeatherErrorFilter {
async catch(err: WeatherEmptyDataError, ctx: Context) {
ctx.logger.error(err);
return '<html><body><h1>weather data is empty</h1></body></html>';
}
}
请求成功后会组装view
view/info.html
<!DOCTYPE html>
<html>
<head>
<title>天气预报</title>
<style>
.weather_bg {
background-color: #0d68bc;
height: 150px;
color: #fff;
font-size: 12px;
line-height: 1em;
text-align: center;
padding: 10px;
}
.weather_bg label {
line-height: 1.5em;
text-align: center;
text-shadow: 1px 1px 1px #555;
background: #afdb00;
width: 100px;
display: inline-block;
margin-left: 10px;
}
.weather_bg .temp {
font-size: 32px;
margin-top: 5px;
padding-left: 14px;
}
.weather_bg sup {
font-size: 0.5em;
}
</style>
</head>
<body>
<div class="weather_bg">
<div>
<p>
{{city}}({{ptime}})
</p>
<p class="temp"></p>
<p>
气温<label>{{temp1}} ~ {{temp2}}</label>
</p>
<p>
天气<label>{{weather}}</label>
</p>
</div>
</div>
</body>
</html>
正常渲染的view以及错误拦截都需要依赖一些配置
npm i @midwayjs/koa --save
npm i @midwayjs/view-nunjucks --save
import { App, Configuration } from '@midwayjs/decorator';
import { ILifeCycle } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import { join } from 'path';
import * as egg from '@midwayjs/web';
import * as view from '@midwayjs/view-nunjucks';
import { WeatherErrorFilter } from './filter/weather';
@Configuration({
imports: [egg, view],
importConfigs: [join(__dirname, './config')],
})
export class ContainerLifeCycle implements ILifeCycle {
@App()
app: koa.Application;
async onReady() {
this.app.useFilter([WeatherErrorFilter]);
}
}
可以写测试来验证 test/weather.ts
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Framework, Application } from '@midwayjs/koa';
describe('test/controller/weather.test.ts', () => {
let app: Application;
beforeAll(async () => {
// create app
app = await createApp<Framework>();
});
afterAll(async () => {
// close app
await close(app);
});
it('should test /weather with success request', async () => {
// make request
const result = await createHttpRequest(app)
.get('/weather')
.query({ cityId: 101010100 });
expect(result.status).toBe(200);
expect(result.text).toMatch(/北京/);
});
it('should test /weather with fail request', async () => {
const result = await createHttpRequest(app).get('/weather');
expect(result.status).toBe(200);
expect(result.text).toMatch(/weather data is empty/);
});
});
npm run test
也可以运行浏览器看看,正常和错误的效果:
2.5 react/semi-design
2.5.1 初始化react项目
新建experience-monitor-client项目。
- 初始化
参考脚手架教程进行配置。
3. 项目开发
3.1 登录注册
3.1.1 设计(业务逻辑/UI)
3.1.2 页面开发
src/pages/login/index.tsx
import React from 'react';
import Footer from '@/components/Footer';
import LoginForm from './form';
import LoginBanner from './banner';
import { Layout } from 'antd';
import './index.less';
const { Content, Sider } = Layout;
function Login() {
return (
<Layout className='container'>
<Sider width='550' className='slider'>
<div className='logo' />
<div className='logo-text'>体验监控管理平台</div>
<LoginBanner />
</Sider>
<Content className='content'>
<Layout>
<Content className='inner-content'>
<LoginForm />
</Content>
<Footer />
</Layout>
</Content>
</Layout>
);
}
Login.displayName = 'LoginPage';
export default Login;
src/pages/login/form.tsx
import { Form, Checkbox, Input, Button } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import axios from 'axios';
import useStorage from '@/utils/storage';
import { useNavigate } from 'react-router-dom';
type LoginParams = {
name: string;
password: string;
agree: boolean;
};
const Component: React.FC = () => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState('');
const [loading, setLoading] = useState(false);
const [loginParams, setLoginParams, removeLoginParams] = useStorage('loginParams');
const [rememberPassword, setRememberPassword] = useState(!!loginParams);
function afterLoginSuccess(params: LoginParams) {
// 记住密码
if (rememberPassword) {
setLoginParams(JSON.stringify(params));
} else {
removeLoginParams();
}
// 记录登录状态
localStorage.setItem('userStatus', 'login');
// 跳转首页
navigate('/home');
}
function login(params: LoginParams) {
setErrorMessage('');
setLoading(true);
axios
.post('/api/user/login', params)
.then(res => {
const { status, msg } = res.data;
if (status === 'ok') {
afterLoginSuccess(params);
} else {
setErrorMessage(msg || '登录出错');
}
})
.finally(() => {
setLoading(false);
});
}
const handleSubmit = (values: any) => {
console.log('Received values of form: ', values);
login(values);
};
return (
<Form
name='normal_login'
className='login-form'
initialValues={{ remember: true }}
onFinish={handleSubmit}
>
<div className='login-form-title'>登录体验监控管理平台</div>
<div className='login-form-sub-title'>登录体验监控管理平台</div>
<div className='margin12' />
<Form.Item name='username' rules={[{ required: true, message: '用户名未填写!' }]}>
<Input
prefix={<UserOutlined className='site-form-item-icon' />}
placeholder='请填写用户名'
/>
</Form.Item>
<Form.Item name='password' rules={[{ required: true, message: '密码未填写!' }]}>
<Input
prefix={<LockOutlined className='site-form-item-icon' />}
type='password'
placeholder='请填写密码'
/>
</Form.Item>
<Form.Item>
<Form.Item name='agree' valuePropName='checked' noStyle>
<Checkbox>我已经阅读服务条款</Checkbox>
</Form.Item>
<a className='login-form-forgot' href=''>
忘记密码
</a>
</Form.Item>
<Form.Item>
<Button type='primary' htmlType='submit' className='login-form-button'>
登录
</Button>
<a href=''>注册</a>
</Form.Item>
{errorMessage}
</Form>
);
};
export default Component;
banner、footer略
3.1.3 接口开发
TODO