基于AIOHTTP、Websocket和Vue3一步步实现web部署平台,无延迟控制台输出,接近原生SSH连接

embedded/2025/2/13 17:26:11/

背景:笔者是一名Javaer,但是最近因为某些原因迷上了Python和它的Asyncio,至于什么原因?请往下看。在着迷”犯浑“的过程中,也接触到了一些高并发高性能的组件,通过简单的学习和了解,aiohttp这个组件引起了我极大的兴趣。

协程、异步非阻塞、”吓人“的性能,这些关键词让我不得不注意到它。

老样子,我们先看成品,再讲讲我曲折的过程。

读取实时日志

 构建+部署

技术痛点揭露

相信大家一定遇到过笔者这次的场景:

        疫情隔离,居家办公,这次我们做的是一个小程序,前端的小伙伴们要联调接口,可是不能用公司的资源,因为公司都断电了😭 ,于是乎我自己买(bai)了(piao)云服务器,自己搭建了一套环境,用自己的域名给他们架上了。本以为事情解决了,前后端可以愉快地调试接口了,但是想都别想,现实还是无情地用它宽大的手掌啪啪打我的三寸小脸。

        你看,后端的小伙伴写完代码,改完bug,提交了之后,一次又一次的让你部署,导致吃饭都想着部署,每次都是噼里啪啦一堆命令,脑瓜子嗡嗡的。(First Blood!)

        你再看,后端猿小帅和前端媛小美正在对接接口,小美说接口怎么一直报错?小帅眉头一皱,手一抖,微信窗口多出个小表情,一脸无辜:"我本地可以啊!"。对,就是这句话,“我本地可以啊,为啥线上不行”,成了接口对接中的口头禅。完了,肯定又是我的活,这不,咔咔一顿"艾特",让我帮忙看日志,啊啊啊啊啊啊,一天到晚登上服务器看了不下N次日志,我的头发在抗议。(Double Kill!)

        你再再再看,我们对接用的yapi,在接口未完成之前,前端调用的是mock,完成之后,得切换到真实接口。为了保证项目开发进度,让前后端的联调顺滑如丝,那付出的肯定是我了。一天下来,在忙上面事情的同时,我还在不断地调整Nginx反向代理配置,为他们放开一个个接口的代理。我内心只能说:mmp。(Triple Kill!)

        你再再再再看看,正常开发过程中总有些粗心捣蛋的人,提交的代码像一个炸弹。这不,刚刚这哥们还在小区楼底下蹦迪,下一秒回家晕乎乎地写了几个bug,潇洒提交,又蹦迪去了。这不提交不要紧,一提交之后,紧接着我习惯性的部署上去,一系列的连锁反应导致几个接口不能用了,兄弟们叫苦不迭,要不是居家,我真想上去抽那仁兄几个嘴巴子。这屁股还是得我擦,回退到上个版本,先凑合调试着。这种操作隔三差五在上演,也是麻烦的很。。。(Quadra kill!)

        最后脑补一个五杀(Penta kill!)🧠

尝试曲线救国

        上面列举了那么多痛点,是个人都被折磨的够呛吧,拜托🙏🏻,疫情即使在家办公也是要高效,更何况家人都在身边,不能焦躁,不能焦躁,不能焦躁!

        这个时候就有大佬说了,你搞这么多费力不讨好的事情,为啥不直接用CI/CD(持续集成)呢?我花费了5根头发想了想,我这1GB内存,1核CPU还能再战吗?再摸了摸我那比纸都薄的钱包,最后点了三炷香“祭奠”了一下我死去的5根头发,心里默默说了声,算了,忍忍,你可以的。

方案一   脚本大法 + 代理

        我开始尝试写脚本。我们的项目是微服务,正常部署都应该用docker-compose,或者直接上到k8s集群里,但是非常时期我们没有办法,只能人工部署。所以我写了一个又一个的脚本,然后写好备注,然后写了一个Low到爆的 HTML,写了超级烂的几行Java代码来调用这些脚本。最后通过Nginx给他们代理出去,把URL分发出去让他们自己点。

        我花费了几个小时完成了上述工作,就在我以为万事大吉的时候,我发现我服务器进不去了。。。WTF?登上控制台,看到CPU使用率125%?我就一个核怎么还超过100捏?虚机超频?呸呸呸,言归正传,排查了半天,我发现是因为多个人短时间内执行构建脚本和部署脚本,直接启动多个进程把机器“干”死了,我摇了摇头,方案1?去你的吧。

方案二  方案一的“进化”

        鉴于方案一存在的致命短板,我不得不针对这个问题进行优化,优化的手段嘛,不出大家所料,还是脚本,用low到爆的一个办法:每次运行构建,都通过 ps | grep | xargs kill -9 杀死之前的进程,再进行构建。运行结果也增加了反馈,用户执行结果会根据Sheel执行返回值进行判断,给出成功与否的响应。至于触发方法嘛,当然是老样子, 继续HTML点击,Java调用脚本。

        再次试验效果,我组织了一场视频会议,会议上我让小美和阿伟还有阿强分别点击部署,哇,效果嘎(chao)嘎(la)的(ji),小美先点击的居然部署成功了,阿伟和阿强后来的居然被杀了?awsl(阿伟死了)。后来排查了半天,发现小美家的wifi只有一格信号,请求发到后台慢了。总体来说方案二的可用度提高了,但是依然没什么卵用,小美提交的代码运行一会后报错了,原因是阿伟提交的一段代码引用了jdk中sun包内的东西,服务器openjdk没有相关类,服务压根没起来。还是很鸡肋。

方案三 另辟蹊径

        方案一和方案二都是短时间内拍脑门儿想出来的活,到现在为止我已经发现问题不能这么草率的解决了, 否则永远都是不断地返工。我深刻地分析了一下,作为一个完备的协同部署功能,至少需要满足以下几个条件:

1. 能够协同工作和实时交互。看了比较大的运维平台,基本上都具备实时的反馈,接近SSH会话级别的体验,能够确认当前的部署状态和部署进度,用户可以及时发现并避免和其他人的交叉使用。此外,如果可能的话,应该实现当前部署状态未完成,其他用户不可操作服务器。

2. 能够查看实时日志。系统运行的状况如何,应该具备日志查看的入口,这些入口开放给开发人员,才能够做到每个人都能及时处理自己的问题。此外,日志滚动频率过快,应该提供“暂停日志”和“恢复日志”的能力。

3. 实现用户身份标识。该功能也是必须的,因为通过HTML按钮点击部署出了问题,往往无法追溯是谁干的😭。后面,我设计为每个用户提供身份标识确认,通过线下发放key的方式提供服务的使用权限,每个key可以绑定到具体的用户,绑定key后,才可以正常使用运维能力。

4. 具有版本控制和一键回滚。一个合格的部署平台,必须具有防范风险的能力,体现在健壮性上来说,就是版本控制。利用shell脚本实现版本控制并不难,实现一键回滚也不难,难的是库表结构修改后产生的种种恩怨情仇。

        经过系统的分析之后,我们说干就干。

= 开始干活 =

        工欲善其事,必先利其器。干活前老样子,先做技术选型。为了一步到位,我直接选择了Vue3去写前端,后端压根没想着用Java去写,因为我写过很多pipleline的代码,java处理起来冗长又效率低下,果断选择了Python大法。事实证明,我的选择太明智了。

搭建Vue项目

技术栈

  • Vue 3
  • Ant Design Vue 3.1.1
  • Socket.io-client
  • CodeMirror Editor
  • Axios

我们使用最新的vue-cli搭建项目。

1. 环境准备

# 安装 Vue CLI
npm install -g @vue/cli# 创建项目
vue create web# 安装依赖
cd web
yarn add ant-design-vue @ant-design/icons-vue axios socket.io-client codemirror-editor-vue3

2. 项目配置

babel.config.js - 按需加载配置
module.exports = {presets: ['@vue/cli-plugin-babel/preset'],plugins: [["import", { "libraryName": "ant-design-vue", "libraryDirectory": "es", "style": "css" }]]
}
vue.config.js - 开发服务器配置
module.exports = defineConfig({transpileDependencies: true,devServer: {port: 7777,proxy: {'/api': {target: 'http://localhost:8090',ws: false,changeOrigin: true,},}},
})

2. 核心功能实现

1. WebSocket 通信模块

项目使用 Socket.io 实现与后端的实时通信:

const wsConnect = (write, done) => {const handler = (data) => {// 处理管道数据const { success, end, content, msg = '' } = dataif (success) {content && write(content.replaceAll('\0', ' '))end && write('\n任务执行完毕', done)} else {write('\n管道读取失败!' + msg, done)}}return {io: null,async connect() {this.io = io(WS_URL, {transports: ['websocket'],query: { token }}).on('pipeline', this.handler.bind(this))},// 发送请求request(event, data = {}) {return new Promise((resolve, reject) => {const rid = Date.now()this.io.emit(event, { rid, ...data })this.pending[rid] = { resolve, reject }})}}
}
2. 部署控制台实现
<template><div class="deploy"><a-card :body-style="{padding: '10px 24px'}"><div class="opt-group">构建和部署:<a-button v-if="!deploying" :disabled="running" class="primary" type="primary" @click="deploy"><template #icon><build-filled /></template>构建并部署</a-button><a-button v-else :loading="stopping" class="primary" type="danger" @click="stop"><template #icon><close-circle-filled /></template>停止部署</a-button><a-button :disabled="deploying || running" class="primary" type="primary" @click="web"><template #icon><global-outlined /></template>构建部署前端</a-button><a-dropdown-button :disabled="deploying || running" type="danger" @click="restore" @visibleChange="loadHistories"><hourglass-filled />回滚版本<template #overlay><a-menu @click="editFile"><template v-if="histories.length"><a-menu-item :key="file" v-for="file in histories">{{file}}</a-menu-item></template><a-menu-item v-else disabled key="more">暂无可回滚版本</a-menu-item></a-menu></template></a-dropdown-button></div><a-divider class="divider" type="vertical" /><div class="opt-group">运行监控:<a-button v-if="!running" :disabled="deploying" class="primary" type="primary" @click="log"><template #icon><snippets-filled /></template>读取运行日志</a-button><a-button v-else :loading="stopping" class="primary" type="danger" @click="stop"><template #icon><close-circle-filled /></template>停止日志读取</a-button><a-button :disabled="deploying || stopping || running" :loading="restarting" class="primary" type="danger"@click="restart"><template #icon><appstore-filled /></template>重启项目</a-button><a-button v-if="!paused" :disabled="!running" @click="pause"><template #icon><pause-circle-filled /></template>暂停日志</a-button><a-button v-else :disabled="!running" type="primary" @click="play"><template #icon><play-circle-filled /></template>恢复日志</a-button></div><template v-if="admin"><a-divider class="divider" type="vertical" /><div class="opt-group">配置维护:<a-button class="primary" type="primary" @click="editFile"><template #icon><setting-filled /></template>修改配置文件</a-button><a-dropdown trigger="click"><template #overlay><a-menu @click="editFile"><a-menu-item :key="file" v-for="file in files">{{file}}</a-menu-item><a-menu-item key="more">创建脚本...</a-menu-item></a-menu></template><a-button @click="loadFiles">修改项目脚本<DownOutlined /></a-button></a-dropdown></div></template></a-card><a-card><template #extra><a href="#">当前版本v1.5.3</a></template><template #title><code-filled style="margin-right: 10px" />控制台<a-divider type="vertical" /><a v-if="current === 'deploy'">当前:部署日志</a><a v-else>当前:运行日志</a></template><code-mirror ref="editorRef" :height="350" :options="cmOptions" class="console" /></a-card><a-drawerwidth="1000":visible="!!editing.key"title="修改文件内容"placement="right"><code-mirror height="100%" :options="cmOptions" v-model:value="editing.content" class="console" /><template #footer><div style="text-align: center"><a-button style="margin-right: 8px" @click="editing = {content: ''}">取消</a-button><a-button type="primary" :loading="editing.loading" @click="saveFile">保存</a-button></div></template></a-drawer></div>
</template><script>
import { onMounted, ref } from 'vue';
import CodeMirror from 'codemirror-editor-vue3';
import { message, Modal } from 'ant-design-vue';
import {AppstoreFilled,BuildFilled,CloseCircleFilled,CodeFilled,DownOutlined,GlobalOutlined,HourglassFilled,PauseCircleFilled,PlayCircleFilled,SettingFilled,SnippetsFilled,
} from '@ant-design/icons-vue';
// import base style
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/material-darker.css'
// language
import 'codemirror/mode/javascript/javascript.js';
import createSocket from '@/api/pipeline';export default {name: 'DeployPage',components: {CodeMirror,CodeFilled,GlobalOutlined,BuildFilled,SettingFilled,HourglassFilled,SnippetsFilled,PauseCircleFilled,PlayCircleFilled,CloseCircleFilled,AppstoreFilled,DownOutlined},// 在我们的组件中setup() {// socketioconst socket = ref(null);// 编辑器实例const editorRef = ref(null);const editor = ref(null);// 活跃的回调const callback = ref(null);// 日志运行状态const running = ref(false);// 日志暂停状态const paused = ref(false);// 部署运行状态const deploying = ref(false);// 重启运行状态const restarting = ref(false);// 通用停止按钮状态const stopping = ref(false);// 配置文件列表const files = ref([]);// 历史版本列表const histories = ref([]);// 当前控制台视图,支持deploy部署、log日志const current = ref('deploy');// 运行管道信息const runningKey = ref('');// 缓存内容const cache = ref('');// 当前编辑文件const editing = ref({});// 目标对应字段const dicts = {set deploy(value) {deploying.value = value;},get deploy() {return deploying.value;},set web(value) {deploying.value = value;},get web() {return deploying.value;},set log(value) {running.value = value;},get log() {return running.value;},set restart(value) {restarting.value = value;running.value = value;if (!value) {appender('\n已完成重启,请读取日志查看')}},get restart() {return restarting.value;},set restore(value) {deploying.value = value;},get restore() {return deploying.value;},};// 日志追加器const appender = (text, end, clear) => {if (clear) {return editor.value?.setValue(text || '')}if (text) {// 如果暂停了,进缓存if (paused.value) {cache.value += text;} else {editor.value?.replaceRange(text, { line: Infinity });editor.value?.scrollTo(0, Infinity);}}// 具有回调,代表结束,做一些重置if (end) {end();cache.value = '';paused.value = false;restarting.value = false;runningKey.value = '';}};// 建立pipeline并读取const connector = async (target) => {current.value = target;callback.value = error => {if (error) appender('\n连接中断或异常,' + error);dicts[target] = false;};try {dicts[target] = true;runningKey.value = await socket.value.open(target);appender('\n管道建立成功!进程id:' + runningKey.value + '\n');} catch (e) {appender('\n无法建立管道连接,' + e.message, callback);}}// 挂载后获取实例onMounted(async () => {editor.value = editorRef.value?.cminstance;socket.value = await createSocket(appender, callback).connect();const instance = editor.value;if (instance) {instance.setValue('暂无运行日志\n\n\n\n\n\n\n\n\n\n\n\n\n');instance.focus();}});// 返回命名空间return {editorRef,running,stopping,current,deploying,restarting,paused,files,histories,editing,get admin() {return socket.value?.admin;},play: () => {paused.value = false;const cached = cache.value;cache.value = ''appender(cached);},pause: () => paused.value = true,deploy: async () => connector('deploy'),log: async () => connector('log'),restart: async () => connector('restart'),web: async () => connector('web'),// 停止pipeline并清理stop: async () => {try {stopping.value = true;await socket.value.kill(runningKey.value);appender('\n成功发送杀死指令')} catch (e) {appender('\n杀死作业失败!' + e.message)} finally {stopping.value = false;}},restore: () => {Modal.confirm({title: '请确认操作',content: '该操作会将上次运行的构建结果替换到当前环境运行,并且不可撤销,请确认操作',okText: '我确定',cancelText: '还是不了',onOk: async () => connector('restore'),})},loadFiles: async () => {files.value = await socket.value.listFile();},loadHistories: async visible => {try {histories.value = visible ? await socket.value.listHistory() : [];} catch (e) {message.error(e.message);}},editFile: async ({ key }) => {try {const body = { key };if (!key) {Object.assign(body, await socket.value.createFile('config.json'))} else if (key === 'more') {Object.assign(body, await socket.value.createFile())} else {body.content = await socket.value.getFile(key)}editing.value = body;} catch (e) {message.warn(e.message || e);}},saveFile: async () => {const close = message.loading('正在保存中...', 0);try {editing.value.loading = true;const { key, content } = editing.value;await socket.value.saveFile(key, content);editing.value = { content: '' };message.success('保存成功!')} catch (e) {message.error(e.message);} finally {close();editing.value.loading = false;}},cmOptions: {mode: "text/javascript", // Language modetheme: "material-darker", // ThemelineNumbers: true, // Show line numbersmartIndent: true, // Smart indentviewportMargin: 350,indentUnit: 2, // The smart indent unit is 2 spaces in lengthfoldGutter: true, // Code foldingstyleActiveLine: true, // Display the style of the selected row},}},
}
</script><!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {margin: 40px 0 0;
}ul {list-style-type: none;padding: 0;
}li {display: inline-block;margin: 0 10px;
}a {color: #42b983;
}.primary {margin-right: 20px
}.console {}.divider {margin: 0 15px;
}.opt-group {display: inline-block;line-height: 50px;
}@media screen and (max-width: 954px) {.opt-group {display: block;}.divider {display: none;}
}
</style>
3. 项目结构
web/
├── src/
│   ├── components/    # 组件
│   │   └── Deploy.vue # 部署控制台组件
│   ├── App.vue        # 根组件
│   └── main.js        # 入口文件
├── public/
│   └── banner.jpg     # 静态资源
├── babel.config.js    # babel配置
└── vue.config.js      # Vue CLI配置

至此,我们实现了:

  • 实时部署状态监控
  • 运行日志实时查看
  • 配置文件在线编辑
  • 版本回滚功能
  • 项目重启功能

总结一下,这波操作采用 Vue 3 + Ant Design Vue 的技术栈,实现了一个功能完整的智能部署控制台。通过 WebSocket 实现了与后端的实时通信,使用 CodeMirror 提供了良好的代码编辑体验。

Python异步部署服务端

经过技术的吸收,我实现了基于Python 3.9+的异步部署工具,主要特点:

  • 基于WebSocket的全双工实时通信
  • 支持自定义部署脚本
  • 支持配置热加载
  • 支持多用户管理
  • 支持部署历史版本管理

技术栈

  • Python 3.9+

  • aiohttp - 异步Web框架

  • python-socketio - WebSocket库

  • SQLite3 - 轻量级数据库

  • watchdog - 文件监控

核心实现

1. WebSocket服务器

使用python-socketio实现WebSocket服务器:

# 初始化socketio服务器
sio = socketio.AsyncServer(async_mode='aiohttp',cors_allowed_origins=['http://localhost:7777', 'http://deploy.flyfish.group'])
app = web.Application()
sio.attach(app)# 处理连接事件
@sio.event
async def connect(sid, environ):user = validate_token(environ['aiohttp.request'], sid)# 缓存客户端clients[sid] = {'process': None, 'killed': False, 'name': user['name']}await send(sid, {'success': True, 'user': {'name': user['name'], 'authority': user['authorities']}})# 处理断开事件
@sio.event 
async def disconnect(sid):if sid in clients:process = clients[sid]['process']if process:await kill_pipeline(sid, {'pid': process.pid})del clients[sid]
2. 异步管道实现

使用asyncio.create_subprocess_shell创建子进程,实现命令执行:

# 打开管道
@sio.event
async def open_pipeline(sid, message):# 取得类型和命令pipe_type = message['type']command = configs['scripts'][pipe_type]# 启动子进程proc = await asyncio.create_subprocess_shell(f'cd {configs["work_dir"]} && {command} {client["name"]}',stdout=asyncio.subprocess.PIPE,preexec_fn=os.setsid)# 返回成功await send(sid, {'success': True, 'pid': proc.pid, 'rid': message['rid']})# 等待提交await submit(sid, proc)# 异步读取输出
async def submit(sid, proc):item = clients[sid]item['process'] = procwhile True:# 异步读取输出line = await proc.stdout.read(BLOCK_SIZE)if not line:break# 实时推送到客户端    await send(sid, {'success': True, 'content': str(line, encoding='utf-8')})
3. 配置热加载

使用watchdog监控配置文件变化:

# 配置文件监听器
class ConfigFileHandler(FileSystemEventHandler):def on_modified(self, event):path = event.src_pathif path.endswith('config.json'):print("修改了配置文件,尝试加载...")if load_config():print('😊配置文件已经重载')# 初始化监听
async def init_app():observer = Observer()observer.schedule(ConfigFileHandler(), './')observer.start()load_config()return app
4. 数据库操作封装

使用上下文管理器封装SQLite操作:

class SqlSession:def execute(self, sql, param=()):with self.conn:cursor = self.conn.cursor()try:return cursor.execute(sql, param)except sqlite3.Error as e:cursor.close()raise eclass SqlOperation:# 插入操作def insert(self, data):if 'id' in data:del data['id']data['create_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')keys = data.keys()values = data.values()sql = f'insert into {self.table} ({",".join(keys)}) values ({",".join(["?"] * len(keys))})'self.session.execute(sql, tuple(values))
5. Webhook实现

实现Gitee的Webhook接收,能够自动部署,持续集成:

@app.route('/deploy', methods=['POST'])
def post_data():# 验证tokentoken = request.headers.get('X-Gitee-Token')if token != gitee_secret:return "token认证无效", 401# 获取推送信息data = json.loads(request.data)name = data['pusher']['name']# 执行部署脚本os.system(f'sh deploy.sh {name}')return jsonify({"status": 200})

项目结构

hooks/
├── bin/                # 核心代码
│   ├── app.py         # WebSocket服务器
│   ├── db.py          # 数据库操作
│   ├── hook.py        # Webhook接收器
│   └── post.py        # 消息推送
├── logs/              # 日志目录
└── requirements.txt   # 依赖配置

总结

到此,我们实现了以下所有能力

1. 全异步通信

  • 使用aiohttp和python-socketio实现全双工通信
  • 异步子进程管理,实时输出
  • 支持多用户并发操作
  • 实时配置
  • 配置文件热加载
  • 支持自定义部署脚本
  • 支持工作目录配置
  • 用户管理
  • 基于Token的认证
  • 会话管理
  • 权限控制

4. 部署管理

  • 支持部署历史
  • 支持版本回滚
  • 支持运行日志查看

通过以上努力,我采用Python异步编程实现了一个功能完整的部署工具,通过WebSocket实现了与前端的实时通信,支持多用户并发操作。

结束语 - 让部署不再是996的理由 🚀

写在最后

各位看官读到这里,相信你已经发现这不是一个普通的部署工具,而是一个能让你告别"部署恐惧症"的神器!

从此告别的场景 😂

  • 再也不用半夜被运维电话叫醒:"服务器挂了!"
  • 不用每次部署都像在玩俄罗斯轮盘赌
  • 告别"在我电脑上能运行"系列尴尬
  • 不用为搞错配置而痛哭流涕

你将收获的快乐 🎉

  • 一键部署,比订外卖还快
  • 实时日志,像看抖音一样上瘾
  • 版本回滚,时光机般的存在
  • 配置热加载,改完配置说走就走

写给犹豫的你 🤔

如果你还在为以下问题困扰:

  • 部署靠"祈祷"
  • 改配置要"跪求"
  • 看日志要"冥想"
  • 回滚要"许愿"

那么,来试试这个工具吧!它不仅能让你的部署工作变得轻松愉快,还能让你在同事面前装个小小的技术大佬。😎

彩蛋时间 🎮

知道为什么我们选择 WebSocket 吗?

  • 因为 HTTP 太慢了,慢得像极了周一的早晨
  • 因为实时通信,快得像极了发工资的瞬间
  • 因为全双工通信,比你谈恋爱还要双向奔赴

最后的最后 🌟

记住,这个工具的诞生不是为了让你加班,而是为了让你有更多时间:

  • 摸鱼 🐟
  • 追剧 📺
  • 打游戏 🎮
  • 谈恋爱 💑

如果这个项目帮你节省了时间,别忘了给我们点个星⭐️

如果没帮你节省时间...那一定是你还没用熟练 😅

愿你的每一次部署,都像喝可乐一样爽快!

愿你的每一次发版,都像春游一样愉快!

愿你的每一次回滚,都像退货一样简单!

结语中的结语 📝

记住我们的口号:

> 部署不再难,生活更自然!

>

> 配置不用愁,周末早回家!

>

> 日志一目了然,Bug无处遁藏!

最后送大家一句话:

> 工具再好,也补不了你的bug!

>

> 但至少...它能让你改bug的时候心情好一点!😊

好了,快去试试吧!让我们一起告别996,拥抱995.9!🎯


注:本项目副作用可能包括但不限于:让你对其他部署工具产生严重的依赖性鄙视,让你的同事对你投来羡慕的眼光,让你的老板觉得你太闲需要安排更多任务... 😜

代码下载

🎉 是的!我们开源啦!

💝 为什么要开源?

因为我们相信:

  • 好的代码应该像老婆的美貌一样,值得炫耀
  • 优秀的项目应该像奶茶一样,值得分享
  • 牛逼的工具应该像八卦一样,让更多人知道

下载地址奉上,希望大家支持!开发不易,请尊重博主的劳动成果!

https://download.csdn.net/download/wybaby168/90373568


http://www.ppmy.cn/embedded/161916.html

相关文章

React(7)

hooks 封装自定义hook通用思路 //1.声明一个以use打头的函数 // 2.在函数体内封装可复用的逻辑(只要是可复用的逻辑) // 3.把组件中用到的状态或者回调return出去(以对象或者数组) // 4.在哪个组件中要用到这个逻辑&#xff0c;就执行这个函数&#xff0c;解构出来状态和回调进…

Springboot 中如何使用Sentinel

在 Spring Boot 中使用 Sentinel 非常方便&#xff0c;Spring Cloud Alibaba 提供了 spring-cloud-starter-alibaba-sentinel 组件&#xff0c;可以快速将 Sentinel 集成到你的 Spring Boot 应用中&#xff0c;并利用其强大的流量控制和容错能力。 下面是一个详细的步骤指南 …

ubuntu22.04 git clone问题

ubuntu22.04 git clone问题 问题1 解决办法&#xff1a; 原因&#xff1a; 可能是HTTP/2协议出现的问题&#xff0c;同时数据传输量过大。

chrome-mojo idl

概述 Mojom 是 Mojo 接口的 IDL。给定一个.mojom文件&#xff0c;绑定生成器可以输出的语言&#xff1a;C、JavaScript或Java。 举一个简单的例子&#xff0c;考虑我们写入的以下假设的 Mojom 文件//services/widget/public/mojom/frobinator.mojom&#xff1a; module widg…

《刚刚问世》系列初窥篇-Java+Playwright自动化测试-20- 操作鼠标拖拽 - 上篇(详细教程)

1.简介 本文主要介绍两个在测试过程中可能会用到的功能&#xff1a;在selenium中宏哥介绍了Actions类中的拖拽操作和Actions类中的划取字段操作。例如&#xff1a;需要在一堆log字符中随机划取一段文字&#xff0c;然后右键选择摘取功能。playwright同样可以实现元素的拖拽和释…

Java面试——Tomcat

优质博文&#xff1a;IT_BLOG_CN 一、Tomcat 顶层架构 Tomcat中最顶层的容器是Server&#xff0c;代表着整个服务器&#xff0c;从上图中可以看出&#xff0c;一个Server可以包含至少一个Service&#xff0c;用于具体提供服务。Service主要包含两个部分&#xff1a;Connector和…

android studio下载安装汉化-Flutter安装

1、下载android studio官方地址&#xff1a;&#xff08;这个网址可能直接打不开&#xff0c;需要VPN&#xff09; https://developer.android.com/studio?hlzh-cn mac版本分为X86和arm版本&#xff0c;电脑显示芯片是Inter的就是x86的&#xff0c;显示m1和m2的就是arm的 …

Docker 部署 MYSQL-5.7 主从

一、拉取对应镜像 docker pull registry.cn-hangzhou.aliyuncs.com/farerboy/mysql:5.7 二、部署主节点 1、创建对应需要的目录 mkdir -p /wwwroot/opt/docker/mysql-master-3306/log mkdir -p /wwwroot/opt/docker/mysql-master-3306/data mkdir -p /wwwroot/opt/docker/m…