背景:笔者是一名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 = '' } = data
if (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-drawer
width="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() {
// socketio
const 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 mode
theme: "material-darker", // Theme
lineNumbers: true, // Show line number
smartIndent: true, // Smart indent
viewportMargin: 350,
indentUnit: 2, // The smart indent unit is 2 spaces in length
foldGutter: true, // Code folding
styleActiveLine: 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'] = proc
while 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_path
if 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 e
class 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():
# 验证token
token = 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!🎯
注:本项目副作用可能包括但不限于:让你对其他部署工具产生严重的依赖性鄙视,让你的同事对你投来羡慕的眼光,让你的老板觉得你太闲需要安排更多任务... 😜
代码下载
🎉 是的!我们开源啦!
💝 为什么要开源?
因为我们相信:
- 好的代码应该像老婆的美貌一样,值得炫耀
- 优秀的项目应该像奶茶一样,值得分享
- 牛逼的工具应该像八卦一样,让更多人知道
下载地址奉上,希望大家支持!开发不易,请尊重博主的劳动成果!