. Context 對象
Koa 提供一個 Context 對象,表示一次對話的上下文(包括 HTTP 請求和 HTTP 回復)。通過加工這個對象,就可以控制返回給用戶的內容。
Context.response.body屬性就是發送給用戶的內容
const Koa=require('koa'); const app=new Koa(); const main=ctx=> { ctx.response.body='Hello World'; }; app.use(main); app.listen(3000); 復制代碼
上面代碼中,main函數用來設置ctx.response.body。然后,使用app.use方法加載main函數。
你可能已經猜到了,ctx.response代表 HTTP Response。同樣地,ctx.request代表 HTTP Request。
2. 路由
原生路由用起來不太方便,我們可以使用封裝好的koa-route模塊
const route=require('koa-route'); const about=ctx=> { ctx.response.type='html'; ctx.response.body='<a href="/">Index Page</a>'; }; const main=ctx=> { ctx.response.body='Hello World'; }; app.use(route.get('/', main)); app.use(route.get('/about', about)); 復制代碼
3. 靜態資源
如果網站提供靜態資源(圖片、字體、樣式表、腳本......),為它們一個個寫路由就很麻煩,也沒必要。koa-static模塊封裝了這部分的請求。
const path=require('path'); const serve=require('koa-static'); const main=serve(path.join(__dirname)); app.use(main); 復制代碼
4. 中間件
const logger=(ctx, next)=> { console.log(`${Date.now()} ${ctx.request.method} ${ctx.request.url}`); next(); } app.use(logger); 復制代碼
像上面代碼中的logger函數就叫做"中間件"(middleware),因為它處在 HTTP Request 和 HTTP Response 中間,用來實現某種中間功能。app.use()用來加載中間件。
基本上,Koa 所有的功能都是通過中間件實現的,前面例子里面的main也是中間件。每個中間件默認接受兩個參數,第一個參數是 Context 對象,第二個參數是next函數。只要調用next函數,就可以把執行權轉交給下一個中間件
5. express和koa中間件對比
express中間件是一個接一個的順序執行 koa中間件是按照圓圈循環進行,即從外層到內層,又從內層回到外層來結束。
者:liu6
轉發鏈接:https://juejin.im/post/5e6e088ff265da57434bd2b1
├── bin # 命令行工具命令
│ ├── start.js # zuodeploy start 執行入口
│ └── zuodeploy.js # zuodeploy 命令入口,在 package.json 的 bin 屬性中配置
├── docImages # README.md 文檔圖片
├── frontend # 客戶端頁面/前端操作頁面(koa-static 靜態服務指定目錄)
│ └── index.html # Vue + ElementUI + axios + socket.io
├── server # 服務端
│ ├── utils
│ │ ├── logger.js # log4js
│ │ └── runCmd.js # node child_process spawn(執行 shell 腳本、pm2 服務開啟)
│ └── index.js # 主服務(koa 接口、靜態服務 + socket + 執行 shell 腳本)
├── .eslintrc.cjs # eslint 配置文件 + prettier
├── args.json # 用于 pm2 改造后,跨文件傳遞端口、密碼參數
├── CHANGELOG.md # release 版本功能迭代記錄
├── deploy-master.sh # 用于測試,當前目錄開啟服務偶,點擊部署按鈕,執行該腳本
├── index.js # zuodeploy start 執行文件,用于執行 pm2 start server/index.js 主服務
├── package.json # 項目描述文件,npm 包名、版本號、cli 命令名稱、
├── publish.sh # npm publish(npm包) 發布腳本
└── README.md # 使用文檔
最初目標:前端頁面點擊部署按鈕,可以直接讓服務器執行部署,并將部署 log 返回給前端
怎么去實現?
技術棧確定:
考慮到前端頁面的部署問題,可以與 koa server 服務放到一起,使用 koa-static 開啟靜態文件服務,支持前端頁面訪問
這里不使用前端工程化 @vue/cli ,直接使用靜態 html,通過 cdn 引入 vue 等
前端服務我們放到 frontend/index.html,koa-static 靜態服務直接指向 frontend 目錄就可以訪問頁面了
核心代碼如下:
注意:cdn 鏈接都是 // 相對路徑,需要使用 http 服務打開頁面,不能以普通的 File 文件形式打開!可以等到后面 koa 寫好后,開啟服務再訪問
<head>
<title>zuo-deploy</title>
<!-- 導入樣式 -->
<link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" />
<!-- 導入 Vue 3 -->
<script src="//unpkg.com/vue@next"></script>
<!-- 導入組件庫 -->
<script src="//unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app" style="margin:0 20px;">
<el-button type="primary" @click="deploy">部署</el-button>
<div>
<p>部署日志:</p>
<div class="text-log-wrap">
<pre>{{ deployLog }}</pre>
</div>
</div>
</div>
<script>
const app={
data() {
return {
deployLog: '點擊按鈕進行部署',
}
},
methods: {
deploy() {
this.deployLog='后端部署中,請稍等...'
axios.post('/deploy')
.then((res)=> {
// 部署完成,返回 log
console.log(res.data);
this.deployLog=res.data.msg
})
.catch(function (err) {
console.log(err);
})
}
}
}
Vue.createApp(app).use(ElementPlus).mount('#app')
</script>
</body>
koa 開啟 http server,寫 deploy 接口處理。koa-static 開啟靜態服務
// server/index.js
const Koa=require("koa");
const KoaStatic=require("koa-static");
const KoaRouter=require("koa-router");
const path=require("path");
const app=new Koa();
const router=new KoaRouter();
router.post("/deploy", async (ctx)=> {
// 執行部署腳本
let execFunc=()=> {};
try {
let res=await execFunc();
ctx.body={
code: 0,
msg: res,
};
} catch (e) {
ctx.body={
code: -1,
msg: e.message,
};
}
});
app.use(new KoaStatic(path.resolve(__dirname, "../frontend")));
app.use(router.routes()).use(router.allowedMethods());
app.listen(7777, ()=> console.log(`服務監聽 ${7777} 端口`));
將項目跑起來
訪問 http:// 127.0.0.1:7777 就可以訪問頁面,點擊部署就可以請求成功了
node 內置模塊 child_process 下 spawn 執行 terminal 命令,包括執行 shell 腳本的 sh 腳本文件.sh 命令
下來看一個 demo,新建一個 testExecShell 測試目錄,測試效果
// testExecShell/runCmd.js
const { spawn }=require('child_process');
const ls=spawn('ls', ['-lh', '/usr']); // 執行 ls -lh /usr 命令
ls.stdout.on('data', (data)=> {
// ls 產生的 terminal log 在這里 console
console.log(`stdout: ${data}`);
});
ls.stderr.on('data', (data)=> {
// 如果發生錯誤,錯誤從這里輸出
console.error(`stderr: ${data}`);
});
ls.on('close', (code)=> {
// 執行完成后正常退出就是 0
console.log(`child process exited with code ${code}`);
});
運行 node testExecShell/runCmd.js 就可以使用 node 執行 ls \-lh /usr,并通過 ls.stdout 接收到 log 信息并打印
回到正題,這里需要執行 shell 腳本,可以將 ls \-lh /usr 替換為 sh 腳本文件.sh 即可。下面來試試
// testExecShell/runShell.js
const { spawn }=require('child_process');
const child=spawn('sh', ['testExecShell/deploy.sh']); // 執行 sh deploy.sh 命令
child.stdout.on('data', (data)=> {
// shell 執行的 log 在這里搜集,可以通過接口返回給前端
console.log(`stdout: ${data}`);
});
child.stderr.on('data', (data)=> {
// 如果發生錯誤,錯誤從這里輸出
console.error(`stderr: ${data}`);
});
child.on('close', (code)=> {
// 執行完成后正常退出就是 0
console.log(`child process exited with code ${code}`);
});
創建執行的 shell 腳本,可以先 sh estExecShell/deploy.sh 試試是否有可執行,如果沒執行權限,就添加(chmod +x 文件名)
# /testExecShell/deploy.sh
echo '執行 pwd'
pwd
echo '執行 git pull'
git pull
運行 node testExecShell/runShell.js 就可以讓 node 執行 deploy.sh 腳本了,如下圖
參考:child\_process \- Node.js 內置模塊筆記[2]
修改之前的 deploy 接口,加一個 runCmd 方法,執行當前目錄的 deploy.sh 部署腳本,完成后接口將執行 log 響應給前端
// 新建 server/indexExecShell.js,將 server/index.js 內容拷貝進來,并做如下修改
const rumCmd=()=> {
return new Promise((resolve, reject)=> {
const { spawn }=require('child_process');
const child=spawn('sh', ['deploy.sh']); // 執行 sh deploy.sh 命令
let msg=''
child.stdout.on('data', (data)=> {
// shell 執行的 log 在這里搜集,可以通過接口返回給前端
console.log(`stdout: ${data}`);
// 普通接口僅能返回一次,需要把 log 都搜集到一次,在 end 時 返回給前端
msg +=`${data}`
});
child.stdout.on('end', (data)=> {
resolve(msg) // 執行完畢后,接口 resolve,返回給前端
});
child.stderr.on('data', (data)=> {
// 如果發生錯誤,錯誤從這里輸出
console.error(`stderr: ${data}`);
msg +=`${data}`
});
child.on('close', (code)=> {
// 執行完成后正常退出就是 0
console.log(`child process exited with code ${code}`);
});
})
}
router.post("/deploy", async (ctx)=> {
try {
let res=await rumCmd(); // 執行部署腳本
ctx.body={
code: 0,
msg: res,
};
} catch (e) {
ctx.body={
code: -1,
msg: e.message,
};
}
});
修改完成后,運行 node server/indexExecShell.js 開啟最新的服務,點擊部署,接口執行正常,如下圖
執行的是當前目錄的 deploy.sh,沒有對應的文件。將上面 testExeclShell/deploy.sh 放到當前目錄再點擊部署
這樣自動化部署基礎功能基本就完成了。
上面的例子中,普通接口需要等部署腳本執行完成后再響應給前端,如果腳本中包含 git pull、npm run build 等耗時較長的命令,就會導致前端頁面一直沒log信息,如下圖
測試 shell
echo '執行 pwd'
pwd
echo '執行 git pull'
git pull
git clone git@github.com:zuoxiaobai/zuo11.com.git # 耗時較長的命令
echo '部署完成'
這里我們改造下,使用 socket.io[3] 來實時將部署 log 發送給前端
socket.io 分為客戶端、服務端兩個部分
客戶端代碼
<!-- frontend/indexSocket.html -->
<script src="https://cdn.socket.io/4.4.1/socket.io.min.js"></script>
<script>
// vue mounted 鉤子里面鏈接 socket 服務端
mounted() {
this.socket=io() // 鏈接到 socket 服務器,發一個 http 請求,成功后轉 101 ws 協議
// 訂閱部署日志,拿到日志,就一點點 push 到數組,顯示到前端
this.socket.on('deploy-log', (msg)=> {
console.log(msg)
this.msgList.push(msg)
})
},
</script>
后端 koa 中引入 socket.io 代碼
// server/indexSoket.js
// npm install socket.io --save
const app=new Koa();
const router=new KoaRouter();
// 開啟 socket 服務
let socketList=[];
const server=require("http").Server(app.callback());
const socketIo=require("socket.io")(server);
socketIo.on("connection", (socket)=> {
socketList.push(socket);
console.log("a user connected"); // 前端調用 io(),即可連接成功
});
// 返回的 socketIo 對象可以用來給前端廣播消息
runCmd() {
// 部分核心代碼
let msg=''
child.stdout.on('data', (data)=> {
// shell 執行的 log 在這里搜集,可以通過接口返回給前端
console.log(`stdout: ${data}`);
socketIo.emit('deploy-log', `${data}`) //socket 實時發送給前端
// 普通接口僅能返回一次,需要把 log 都搜集到一次,在 end 時 返回給前端
msg +=`${data}`
});
// ...
child.stderr.on('data', (data)=> {
// 如果發生錯誤,錯誤從這里輸出
console.error(`stderr: ${data}`);
socketIo.emit('deploy-log', `${data}`) // socket 實時發送給前端
msg +=`${data}`
});
}
// app.listen 需要改為上面加入了 socket 服務的 server 對象
server.listen(7777, ()=> console.log(`服務監聽 ${7777} 端口`));
我們在之前的 demo 中加入上面的代碼,即可完成 socket 改造,node server/indexSocket.js,打開 127.0.0.1:7777/indexSocket.html,點擊部署,即可看到如下效果。完成 demo 訪問地址[4]
相關問題
ws 這個里面可以看到 socket 傳的數據
上面只是用接口實現的功能,并沒有加權限控制,任何人知道接口地址后,可以通過 postman 請求該接口,觸發部署。如下圖
為了安全起見,我們這里為接口添加鑒權,前端增加一個輸入密碼登錄的功能。這里使用 koa-session 來鑒權,只有登錄態才能請求成功
// server/indexAuth.js
// npm install koa-session koa-bodyparser --save
// ..
const session=require("koa-session");
const bodyParser=require("koa-bodyparser"); // post 請求參數解析
const app=new Koa();
const router=new KoaRouter();
app.use(bodyParser()); // 處理 post 請求參數
// 集成 session
app.keys=[`自定義安全字符串`]; // 'some secret hurr'
const CONFIG={
key: "koa:sess" /** (string) cookie key (default is koa:sess) */,
/** (number || 'session') maxAge in ms (default is 1 days) */
/** 'session' will result in a cookie that expires when session/browser is closed */
/** Warning: If a session cookie is stolen, this cookie will never expire */
maxAge: 0.5 * 3600 * 1000, // 0.5h
overwrite: true /** (boolean) can overwrite or not (default true) */,
httpOnly: true /** (boolean) httpOnly or not (default true) */,
signed: true /** (boolean) signed or not (default true) */,
rolling: false /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */,
renew: false /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/,
};
app.use(session(CONFIG, app));
router.post("/login", async (ctx)=> {
let code=0;
let msg="登錄成功";
let { password }=ctx.request.body;
if (password===`888888`) { // 888888 為設置的密碼
ctx.session.isLogin=true;
} else {
code=-1;
msg="密碼錯誤";
}
ctx.body={
code,
msg,
};
});
router.post("/deploy", async (ctx)=> {
if (!ctx.session.isLogin) {
ctx.body={
code: -2,
msg: "未登錄",
};
return;
}
// 有登錄態,執行部署
})
前端相關改動,加一個密碼輸入框、一個登錄按鈕
<!-- frontend/indexAuth.html 注意id="app"包裹 -->
<div class="login-area">
<div v-if="!isLogin">
<el-input v-model="password" type="password" style="width: 200px;"></el-input>
<el-button type="primary" @click="login">登錄</el-button>
</div>
<div v-else>已登錄</div>
</div>
<script>
data() {
return {
isLogin: false,
password: ''
}
},
methods: {
login() {
if (!this.password) {
this.$message.warning('請輸入密碼')
return
}
axios.post('/login', { password: this.password })
.then((response)=> {
console.log(response.data);
let { code, msg }=response.data
if (code===0) {
this.isLogin=true
} else {
this.$message.error(msg)
}
})
.catch(function (err) {
console.log(err);
this.$message.error(err.message)
})
}
}
</script>
node server/indexAuth.js,打開 127.0.0.1:7777/indexAuth.html,登錄成功之后才能部署
為什么封裝成 npm 包,使用命令行工具開啟服務。主要是簡單易用,如果不使用命令行工具形式,需要三步:
改成 npm 包命令行工具形式只需要下面兩步,而且更節省時間
下面先來看一個簡單的例子,創建一個 npm 包并上傳到 npm 官方庫步驟
// index.js
module.exports={
name: '寫一個npm包',
doSomething() {
console.log('這個npm暴露一個方法')
}
}
# publish.sh
npm config set registry=https://registry.npmjs.org
npm login # 登陸 ,如果有 OTP, 郵箱會接收到驗證碼,輸入即可
# 登錄成功后,短時間內會保存狀態,可以直接 npm pubish
npm publish # 可能會提示名稱已存在,換個名字,獲取使用作用域包(@xxx/xxx)
npm config set registry=https://registry.npm.taobao.org # 還原淘寶鏡像
到 npmjs.org 搜索對應包就可以看到了
使用該 npm 包,創建 testNpm/index.js
const packageInfo=require('zuoxiaobai-test')
console.log(packageInfo)
packageInfo.doSomething()
在 testNpm 目錄下 npm init 初始化 package.json,再 npm install zuoxiaobai-test --save; 再 node index.js,執行情況如下圖,調用 npm 包正常
這樣我們就知道怎么寫一個 npm 包,并上傳到 npm 官方庫了。
下面,我們來看怎么在 npm 包中集成 cli 命令。舉個例子:在 npm install @vue/cli \-g 后,會在環境變量中添加一個 vue 命令。使用 vue create xx 可初始化一個項目。一般這種形式就是 cli 工具。
一般在 package.json 中有一個 bin 屬性,用于創建該 npm 包的自定義命令
// package.json
"bin": {
"zuodeploy": "./bin/zuodeploy.js"
},
上的配置意思是:全局安裝 npm install xx -g 后,生成 zuodeploy 命令,運行該命令時,會執行 bin/zuodeploy.js
本地開發時,配置好后,在當前目錄下運行 sudo npm link 即可將 zuodeploy 命令鏈接到本地的環境變量里。任何 terminal 里面運行 zuodeploy 都會執行當前項目下的這個文件。解除可以使用 npm unlink
一般 cli 都會使用 commander 來生成幫助文檔,管理指令邏輯,代碼如下
// bin/zuodeploy.js
#!/usr/bin/env node
const { program }=require("commander");
const prompts=require("prompts");
program.version(require("../package.json").version);
program
.command("start")
.description("開啟部署監聽服務") // description + action 可防止查找 command拼接文件
.action(async ()=> {
const args=await prompts([
{
type: "number",
name: "port",
initial: 7777,
message: "請指定部署服務監聽端口:",
validate: (value)=>
value !=="" && (value < 3000 || value > 10000)
? `端口號必須在 3000 - 10000 之間`
: true,
},
{
type: "password",
name: "password",
initial: "888888",
message: "請設置登錄密碼(默認:888888)",
validate: (value)=> (value.length < 6 ? `密碼需要 6 位以上` : true),
},
]);
require("./start")(args); // args 為 { port: 7777, password: '888888' }
});
program.parse();
使用 commander 可以快速管理、生成幫助文檔,分配具體指令的執行邏輯
上面的代碼中,指定了 start 指令,zuodeploy start 執行時會先通過 prompts 以詢問的方式搜集參數,再執行 bin/start.js
在 start.js 中,我么可以將 server/index.js 的代碼全部拷貝過去即可完成 zuodeploy start 開啟服務,點擊部署的功能
為了提升穩定性,我們可以在 start.js 中以代碼的方式執行 pm2 src/index.js 這樣服務更穩定可靠,另外可以再加入 log4js 輸出帶時間戳的 log,這樣有利于排查問題。
將上面零碎的知識點匯聚到一起就是 zuo-deploy 的實現,代碼寫的比較隨意,歡迎 star、fork、提改進 PR!
*請認真填寫需求信息,我們會在24小時內與您取得聯系。