??上周二開晨會分配任務(wù)的時候,分配到了一個微信掃碼關(guān)注公眾號的需求。剛開始以為只要截個公眾號二維碼的圖,然后按照UI出的設(shè)計(jì)稿把二維碼放到指定位置,再加上一波加邊框加陰影的操作提交就完事了。所以當(dāng)部門大佬問多久能做完的時候,我毫不猶豫地說:小Case啦,兩天妥妥的!
??回到座位上本想著時間還早,先刷會微博。刷著刷著看到廣州東站的宜家要搬遷的消息,活動大減價全場5折起?。∪滩蛔↑c(diǎn)進(jìn)去宜家的網(wǎng)上商城,看了兩圈好想剁手買買買。這時,部門大佬站了起來,好像正往我這邊看。算了算了,先關(guān)注宜家公眾號干活去吧,拿出手機(jī)準(zhǔn)備掃碼的時候整個人都懵了...
??誒?誒誒?!早上的需求好像是要實(shí)現(xiàn)掃碼關(guān)注公眾號并登陸的,但瀏覽器怎么會知道我掃碼了,而且掃的還是登陸用的二維碼...這怎么跟想象的不一樣,我還打包票說兩天內(nèi)做完,真的完了完了。
??當(dāng)然,兩天后這掃碼的功能還是經(jīng)過測試按時提交,并在線上穩(wěn)定運(yùn)行了一周。如何才能快速實(shí)現(xiàn),并盡量少踩坑,我想,有些思路和和代碼寫下來,以后可能會用得著。
??項(xiàng)目主要以Nodejs進(jìn)行開發(fā),優(yōu)先選用Koa2 + ioredis等一些比較輕量級的模塊實(shí)現(xiàn),配合Alpine Linux制作Docker Image,最后得到的一個開箱即用的Docker鏡像也僅僅只有33M。
??瀏覽項(xiàng)目的完整代碼可以點(diǎn)擊這里github,如果對你有幫助歡迎Star。
先整理一下需求:
- 登錄入口實(shí)現(xiàn)掃描二維碼關(guān)注公眾號并登錄網(wǎng)站。已關(guān)注的直接跳轉(zhuǎn)登陸,未關(guān)注的等待用戶關(guān)注再跳轉(zhuǎn);
- 新的公眾平臺掃碼登錄機(jī)制代替原有的微信開放平臺的掃碼登錄;
- 掃碼關(guān)注后需要根據(jù)情況返回不同的提示(歡迎)信息。
??但目前已上線的網(wǎng)站同時提供微信掃碼和手機(jī)/郵箱注冊登錄,新需求實(shí)際是想讓更多的用戶關(guān)注公眾號。完全按照需求上做的話就會變成強(qiáng)制用戶必須關(guān)注公眾號,否則無法完成登錄??紤]到市場上有不少同類型產(chǎn)品,這種強(qiáng)制的行為可能會導(dǎo)致用戶反感,從而選擇其他產(chǎn)品。
經(jīng)討論需求改為:
- 保留現(xiàn)有的微信掃碼和手機(jī)/郵箱注冊登錄,完成注冊/登錄流程后,新增一個掃碼關(guān)注公眾號的頁面;
- 用戶掃碼關(guān)注,關(guān)注后利用unionid機(jī)制綁定賬戶,讓手機(jī)/郵箱注冊的用戶以后可以直接微信掃碼登錄;
- 點(diǎn)擊關(guān)注后,網(wǎng)站自動跳轉(zhuǎn)進(jìn)入控制臺,或點(diǎn)擊暫不關(guān)注直接跳轉(zhuǎn);
- 掃碼關(guān)注后需要根據(jù)情況返回不同的提示(歡迎)信息。
實(shí)現(xiàn)思路和步驟:
- 實(shí)現(xiàn)一個與微信公眾號平臺交互的API,接收并處理公眾號推送的事件(關(guān)注、掃碼和文字消息等);
- 實(shí)現(xiàn)一個生成二維碼的API供瀏覽器調(diào)用,API可通過參數(shù)聲明需要返回的格式;
- 請求公眾平臺 →【生成帶參數(shù)的二維碼】接口生成帶有場景值的二維碼,生成成功后記錄到數(shù)據(jù)庫并返回;
- 瀏覽器獲取二維碼信息后輪詢二維碼的掃描狀態(tài),掃描成功后自動跳轉(zhuǎn);
- 用戶掃碼后,公眾平臺會向1實(shí)現(xiàn)的API推送事件,如果是關(guān)注就獲取用戶信息,然后記錄到數(shù)據(jù)庫。
第一步,搭建Koa的環(huán)境并接入微信公眾平臺
??提供的源碼里包含刪減過的 Koa2和 koa-router的代碼,也可以使用原版的代碼。建議使用Nodejs10以上版本,特別是Nodejs12,換了新的HTTP解析器(llhttp)性能直接提高了一倍。
安裝依賴
package.json
"dependencies": {
"debug": "^4.1.1",
"got": "^9.6.0",
"ioredis": "^4.10.0",
"mime-types": "^2.1.24",
"negotiator": "^0.6.2",
"xml2js": "^0.4.19",
"ylru": "^1.2.1"
}
如果是直接用官方的 Koa,mime-types,negotiator,ylru都不用安裝
目錄結(jié)構(gòu)

Koa APP的代碼結(jié)構(gòu)跟官方的栗子差不多,就直接看吧
app.js
const http = require('http')
const Koa = require('./vendor/koa2/application')
const XMLParser = require('./middlewares/XMLParser')
const router = require('./routes/wechat')
const app = new Koa()
?
app.use(XMLParser) // 解析xml的中間件,用于預(yù)處理微信公眾號推送的事件
?
app.use(router.routes())
app.use(router.allowedMethods())
?
http.createServer(app.callback()).listen(3000)
middlewares/XMLParser.js
const parseXML = require('xml2js').parseString
const debug = require('debug')('xml-parse')
?
const parse = (req, options = {}) => {
return new Promise((resolve, reject) => {
let xml = ''
req.on('data', chunk => { xml += chunk.toString('utf-8') })
.on('error', reject)
.on('end', () => parseXML(xml, options, (err, res) => {
if (err) reject(err)
resolve(res)
}))
})
}
?
module.exports = async (ctx, next) => {
// 這里先嘗試直接匹配,匹配失敗再到mime庫里查詢
if (ctx.request.type === 'text/xml' || ctx.is('xml')) {
try {
ctx.request.body = await parse(ctx.req)
} catch (e) {
debug(e.message)
}
}
await next()
}
routes/wechat.js
const Router = require('../vendor/koa-router')
const wechatController = require('../controllers/wechat')
?
?
const router = new Router({
prefix: '/wechat'
})
?
// 測試號配置接口信息時需要校驗(yàn),但傳輸?shù)臄?shù)據(jù)跟推送消息一樣,所以放在同一個controller里處理
// conntroller的完整path是/wechat/event,這個后面配置測試號URL的時候會用到
router.get('/', ctx => ctx.body = 'hello wechat')
.get('/event', wechatController)
.post('/event', wechatController)
?
module.exports = router
先新建一個配置文件,與app.js同目錄
config.js
WXMP的信息暫時留空,到配置微信公眾平臺的時候再填寫
const CACHE = {
host: 'localhost',
port: 6379
}
?
// WeChat Media Platform
const WXMP = {
appID: '',
appSecret: '',
token: ''
}
?
module.exports = {
CACHE,
WXMP
}
/controllers/wechat.js
const { WXMP } = require('../config');
const { SHA1 } = require('../utils/mUtils')
?
module.exports = async (ctx, next) => {
const token = WXMP.token
const { signature, nonce, timestamp, echostr } = ctx.query
?
/**
* https://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1421135319
* 1)將token、timestamp、nonce三個參數(shù)進(jìn)行字典序排序
* 2)將三個參數(shù)字符串拼接成一個字符串進(jìn)行sha1加密
* 3)開發(fā)者獲得加密后的字符串可與signature對比,標(biāo)識該請求來源于微信
*/
const str = [token, timestamp, nonce].sort().join('')
?
const signVerified = SHA1(str) === signature
?
if (!signVerified) {
ctx.status = 404 // 可以不設(shè)為404,koa默認(rèn)的狀態(tài)值就是404
return
}
?
if (ctx.method === 'GET') ctx.body = echostr
else if (ctx.method === 'POST') {
// 實(shí)現(xiàn)思路里的第一步
// 推送的消息會以POST的方式進(jìn)到這里,暫時用不著,先放著
}
}
??來到這里,先測試一下Web服務(wù)是否能正常跑起來。這里使用Postman直接發(fā)請求,也可以用瀏覽器訪問http://localhost:3000/wechat/。

接下來配置一下微信公眾平臺。
??線上的環(huán)境經(jīng)不起折騰,還是用公眾號測試號進(jìn)行調(diào)試吧,如果你是剛開始接觸微信公眾號開發(fā),推薦使用測試號。
掃碼登陸后會看到這樣一個界面:

??appID和appsecret是系統(tǒng)生成的,只需要填寫URL和Token。把a(bǔ)ppID和appsecret貼到之前創(chuàng)建的config.js中,token自己隨便輸入,保證兩個token一致即可。配置URL之前,我們需要一個域名和一個內(nèi)網(wǎng)穿透的環(huán)境。舊版本的微信web開發(fā)工具提供一個類似的方式,讓微信服務(wù)器可以向我們在內(nèi)網(wǎng)的機(jī)器推送消息,新版沒有這功能我們就自己百度一個吧。
??粗略比較了一下,發(fā)現(xiàn)續(xù)斷內(nèi)網(wǎng)穿透很大方,只要9.9就有兩條8M永久使用的隧道(只比KFC會員價的原味雞貴4毛,尊貴的VIP吃雞怎么還這么貴(╯‵□′)╯︵┻━┻)。注冊交了9.9入會費(fèi),裝上客戶端后進(jìn)行簡單配置就可以了,體驗(yàn)挺棒的。設(shè)置過程中發(fā)現(xiàn)他家支持好多系統(tǒng),像群暉、OpenWRT那些都有,還有樹莓派,看來吃塵多年的B+可以拿出來發(fā)揮余熱了。好像還有一些有趣的功能,可惜體驗(yàn)隧道不支持。不過只要9.9,還要啥自行車呢,果斷開干吧!

??點(diǎn)擊保存稍等一會會得到一個外網(wǎng)訪問地址,類似http://sd8xxxxxxxxs.gzhttp.cn

??接著把外網(wǎng)訪問地址+之前定義的path(http://sd8xxxxxxxxs.gzhttp.cn/wechat/event)填寫到測試號接口配置的URL中,然后點(diǎn)擊提交。這時我們已經(jīng)成功接入到微信公眾平臺了。
第二步,實(shí)現(xiàn)一個生成二維碼的API并完善與微信公眾號平臺交互的API
??開始第二步之前,先來了解一下創(chuàng)建二維碼及用戶掃碼后公眾號給web服務(wù)推送消息的流程:

??首先在utils/wechat/文件夾中新建一個helper.js,負(fù)責(zé)提供公眾號配置(用于下面創(chuàng)建wechat對象)和get/set access_token的兩個方法。
utils/wechat/helper.js
const { WXMP } = require('../../config')
const { redis } = require('../dbHelper')
?
const config = {
MP: {
appID: WXMP.appID,
appSecret: WXMP.appSecret,
token: WXMP.token,
getAccessToken: async () => {
let token = await redis.get('access_token')
return token
},
saveAccessToken: async (data = {}) => {
await redis.set('access_token', data.access_token ,'EX', data.expires_in)
}
}
}
?
module.exports = {
...config
}
??在utils/wechat/文件夾中新建一個wxmp.js,定義一個Wechat類
...
const api = {
accessToken: 'token?grant_type=client_credential',
user: {
info: 'user/info?',
},
QRCodeTicket: 'qrcode/create?',
QRCode: 'showqrcode?'
}
?
class Wechat {
constructor (opts) {
// 這里的opts傳入的是上面定義的config
...
this.fetchAccessToken(true)
}
// 獲取access_token
async fetchAccessToken (init = false) {
let token = await this.getAccessToken()
?
if (!token) {
token = await this.updateAccessToken()
await this.saveAccessToken(token)
token = token.access_token
}
return token
}
?
async updateAccessToken () {
const url = api.accessToken + '&appid=' + this.appID + '&secret=' + this.appSecret
return await got(url)
}
?
// 提供一個統(tǒng)一操作的入口,第一個參數(shù)傳入操作函數(shù)名就可以拿到對應(yīng)的配置
async handle (operation, ...args) {
const token = await this.fetchAccessToken()
if (!token) return null
?
const options = this[operation](token, ...args)
let res = await wxGot(options)
return res
}
?
// 獲取用戶信息
getUserInfo (token, openID, lang) {
const url = `${api.user.info}access_token=${token}&openid=${openID}&lang=${lang || 'zh_CN'}`
?
return { url: url }
}
?
// 申請二維碼Ticket
getQRCodeTicket (token, sceneStr, timeout) {
return {
url: `${api.QRCodeTicket}access_token=${token}`,
method: 'post',
body: {
"expire_seconds": timeout || 60,
"action_name": "QR_STR_SCENE", // 臨時二維碼
"action_info": {
"scene": {
"scene_str": sceneStr
}
}
}
}
}
?
module.exports = Wechat
??我們繼續(xù)回到剛才的/routes/wechat.js,增加 “+” 標(biāo)識的代碼(新增獲取二維碼的路由)
const Router = require('../vendor/koa-router')
const wechatController = require('../controllers/wechat')
+ const { createQRCodeMB } = require('../controllers/wechat')
?
const router = new Router({
prefix: '/wechat'
})
?
// 測試號配置接口信息時需要校驗(yàn),但傳輸?shù)臄?shù)據(jù)跟推送消息一樣,所以放在同一個controller里處理
// conntroller的完整path是/wechat/event,這個后面配置測試號URL的時候會用到
router.get('/', ctx => ctx.body = 'hello wechat')
.get('/event', wechatController)
.post('/event', wechatController)
+ .get('/qrcode', createQRCodeMB)
?
module.exports = router
然后打開/controllers/wechat.js,公眾號推送事件類型可以參考這里
const { WXMP } = require('../config');
const { SHA1, fmtNormalXML, streamToBuffer, createTimestamp } = require('../utils/mUtils')
const { redis } = require('../utils/dbHelper')
const Wechat = require('../utils/wechat/wxmp')
const MPConfig = require('../utils/wechat/helper').MP
const got = require('got')
const qr = require('../vendor/qr')
const fs = require('fs')
const pathResolve = require('path').resolve
?
const MP = new Wechat(MPConfig)
?
module.exports = async (ctx, next) => {
...
if (ctx.method === 'GET') ctx.body = echostr
else if (ctx.method === 'POST') {
// 把數(shù)組形態(tài)的xmlObject轉(zhuǎn)換可讀性更高的結(jié)構(gòu)
const message = fmtNormalXML(ctx.request.body.xml)
? const msgType = message.MsgType
const msgEvent = message.Event
const userID = message.FromUserName
let eventKey = message.EventKey
let body = null
?
if (msgType === 'event') {
switch (msgEvent) {
// 關(guān)注&取關(guān)
case 'subscribe':
case 'unsubscribe':
body = await subscribe(message)
break
// 關(guān)注后掃碼
case 'SCAN':
body = '掃碼成功'
break
}
if (!!eventKey) {
// 有場景值(掃了我們生成的二維碼)
let user = await MP.handle('getUserInfo', userID)
let userInfo = `${user.nickname}(${user.sex ? '男' : '女'}, ${user.province}${user.city})`
if (eventKey.slice(0, 8) === 'qrscene_') {
// 掃碼并關(guān)注
// 關(guān)注就創(chuàng)建帳號的話可以在這里把用戶信息寫入數(shù)據(jù)庫完成用戶注冊
eventKey = eventKey.slice(8)
console.log(userInfo + '掃碼并關(guān)注了公眾號')
} else {
// 已關(guān)注
console.log(userInfo + '掃碼進(jìn)入了公眾號')
}
?
// 更新掃碼記錄,供瀏覽器掃碼狀態(tài)輪詢
await redis.pipeline()
.hset(eventKey, 'unionID', user.unionid || '') // 僅unionid機(jī)制下有效
.hset(eventKey, 'openID', user.openid)
.exec()
}
}
}
}
?
async function subscribe (message) {
let userID = message.FromUserName
if (message.Event === 'subscribe') {
return '感謝您的關(guān)注'
} else {
// 用戶取消關(guān)注后我們不能再通過微信的接口拿到用戶信息,
// 如果要記錄用戶信息,需要從我們自己的用戶記錄里獲取該信息。
// 所以建議創(chuàng)建用戶時除了unionid,最好把openid也保存起來。
console.log(userID + '取關(guān)了')
}
}
?
const templetData = fs.readFileSync(pathResolve(__dirname, '../vendor/qrcode-templet.html'))
?
// 創(chuàng)建二維碼
async function createQRCodeMB (ctx, next) {
let userID = ctx.query.userID
let type = +ctx.query.type
let errno = 0
let responseDate = {}
let id = createTimestamp()
?
let res = await MP.handle('getQRCodeTicket', id)
?
if (res === null) errno = 1
else {
responseDate = {
expiresIn: res.expire_seconds,
id
}
?
let imgBuffer = await streamToBuffer(qr.image(res.url))
let imgSrc = imgBuffer.toString('base64')
?
if (type === 1) {
// 返回圖片
ctx.body = `<img src="data:image/png;base64,${imgSrc}" />`
} else if (type === 2) {
// 返回一個自帶查詢狀態(tài)和跳轉(zhuǎn)的網(wǎng)頁
let templetValue = `
<script>var imgSrc='${imgSrc}',id='${responseDate.id}',
timeout=${responseDate.expiresIn},width=100,height=100</script>`
?
ctx.body = templetValue + templetData.toString('utf-8')
} else {
// 返回圖片內(nèi)容
responseDate.imgSrc = imgSrc
}
}
?
if (!ctx.body) {
ctx.body = {
errno,
...responseDate
}
}
}
?
module.exports.createQRCodeMB = createQRCodeMB
??到這里應(yīng)該是可以接收到公眾號推送的掃碼事件和生成二維碼。
??保存后我們先測試一下,首先不帶參數(shù)訪問http://localhost:3000/wechat/qrcode

??接著嘗試獲取二維碼圖片(使用參數(shù)type=1)并使用微信掃描二維碼:

??首次掃描二維碼會提示關(guān)注,點(diǎn)擊關(guān)注后數(shù)據(jù)庫就會更新,控制臺也會打印出類似 “XXX掃碼并關(guān)注了公眾號“ 的日志。但這時候公眾號里應(yīng)該會提示 ”該公眾號提供的服務(wù)出現(xiàn)故障,請稍后再試“ 的提示,因?yàn)槌绦虿]有把提示信息正確得返回。下一步我們需要格式化返回的信息(即ctx.body的內(nèi)容)。
新增一個生成模板的文件/utils/tmpl.js
格式化給公眾號返回的消息,這里只簡單使用util.format來格式化消息。
const util = require('util')
?
const msgTemplet = `
<xml>
<ToUserName><![CDATA[%s]]></ToUserName>
<FromUserName><![CDATA[%s]]></FromUserName>
<CreateTime>%d</CreateTime>
<MsgType><![CDATA[%s]]></MsgType>
$msgBody$
</xml>
`
?
const textMsg = `<Content><![CDATA[%s]]></Content>`
const imageMsg = `<Image><MediaId><![CDATA[%s]]></MediaId></Image>`
?
module.exports = (ctx, originMsg) => {
let type = (ctx && ctx.type) || 'text'
let msgTmpl = util.format(msgTemplet,
originMsg.FromUserName,
originMsg.ToUserName,
Math.floor(new Date().getTime() / 1000),
type
)
?
let body = ''
?
switch (type) {
case 'text':
body = util.format(textMsg, ctx)
break
case 'image':
break
default:
body = util.format(textMsg, '操作無效')
}
?
return msgTmpl.replace(/\$msgBody\$/, body)
}
接著我們在controllers/wechat.js增加一下 ”+“ 標(biāo)記的代碼
const { WXMP } = require('../config');
const { SHA1, fmtNormalXML, streamToBuffer, createTimestamp } = require('../utils/mUtils')
+ const { tmpl } = require('../utils/wechat')
const { redis } = require('../utils/dbHelper')
const Wechat = require('../utils/wechat/wxmp')
...
module.exports = async (ctx, next) => {
const token = WXMP.token
const { signature, nonce, timestamp, echostr } = ctx.query
?
const str = [token, timestamp, nonce].sort().join('')
...
// 更新掃碼記錄,供瀏覽器掃碼狀態(tài)輪詢
await redis.pipeline()
.hset(eventKey, 'unionID', user.unionid || '') // 僅unionid機(jī)制下有效
.hset(eventKey, 'openID', user.openid)
.exec()
}
}
?
+ ctx.type = 'application/xml'
+ ctx.body = tmpl(body || ctx.body, message)
}
}
?
async function subscribe (message) {
let userID = message.FromUserName
if (message.Event === 'subscribe') {
return '感謝您的關(guān)注'
} else {
console.log(userID + '取關(guān)了')
}
}
??保存后再獲取一次二維碼并掃描,微信上就能正確顯示提示信息了:

第三步,瀏覽器增加掃碼狀態(tài)輪詢
??這塊跟業(yè)務(wù)代碼關(guān)系比較密切,所以不做詳細(xì)介紹。共通點(diǎn)就是通過二維碼返回id獲取unionid(openid)的記錄,然后按需處理,最后以cookies或其他方式更新登錄狀態(tài)。
輪詢的代碼可以參考vendor/qrcode-templet.html
async function waitToSubscribe(id, timeout) {
let countdown = Math.ceil(timeout / 3);
return new Promise((resolve, reject) => {
const loop = async function() {
let res = await ky.default.get("/wechat/check", {
searchParams: { id }
}).json();
if (!res) return;
if (res.errno === 0) resolve("subscribe");
else if (res.errno === 2) reject("timeout");
else if (countdown-- > 0) self.QRCodeTimer = setTimeout(loop, 3000);
};
loop();
});
};
(async () => {
try {
await waitToSubscribe(id, timeout);
window.location.href = "/wechat/";
} catch (e) {
history.go(0);
}
})();
我們可以嘗試獲取集成好獲取狀態(tài)的二維碼網(wǎng)頁(使用參數(shù)type=2,實(shí)際使用時可以用iframe嵌套):

總結(jié):
??到這里,我們已經(jīng)實(shí)現(xiàn)了:
1. 與微信公眾號平臺交互的API,能夠接收并處理公眾號推送的事件;
2. 生成二維碼的API,并能分別以三種常用方式返回二維碼;
3. 掃描二維碼后,微信上能正常顯示服務(wù)返回的提示信息,并成功記錄在數(shù)據(jù)庫中;
4. 當(dāng)瀏覽器輪詢二維碼的掃描狀態(tài)并獲取到掃描結(jié)果后,自動跳轉(zhuǎn)。
??以上幾乎包含了公眾號開發(fā)的完整流程,其他的功能可以參照公眾號開發(fā)文檔上的說明按需增加。這里有一點(diǎn)需要注意的,文中提到的unionid機(jī)制需要以公司身份申請正式的公眾號和微信開放平臺,并在開放平臺上完成公眾號綁定。同一個用戶在已綁定公眾號、小程序、網(wǎng)站應(yīng)用等程序里會使用同一個unionid來確定用戶的唯一性。
??像公眾號網(wǎng)頁授權(quán)、開放平臺的網(wǎng)站應(yīng)用授權(quán)(類似京東的掃碼登錄)和小程序的開發(fā),等有空的時候再更新。碼了這么多字,差點(diǎn)忘了要去宜家掃貨,廣告上說促銷商品數(shù)量有限,萬一賣完了豈不是錯過了幾個億::>_<::,周末要找個時間過去看看才行。
最后附上Dockerfile 和源碼地址
??預(yù)先拷貝文件到/build目錄,便于生成更小的Docker Image
cp -rf vendor docker/build/vendor
cp -rf utils docker/build/utils
cp -rf routes docker/build/routes
cp -rf middlewares docker/build/middlewares
cp -rf controllers docker/build/controllers
cp app.js docker/build/app.js
cp config.js docker/build/config.js
FROM alpine
COPY package.json /var/www/wechat-mp/
WORKDIR /var/www/wechat-mp
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add nodejs npm \
&& npm install --production --registry=https://registry.npm.taobao.org \
&& npm cache clean -f \
&& rm package-lock.json \
&& apk del npm \
&& rm -rf ~/.npm \
&& rm -rf /var/cache/apk/* \
&& rm -rf /root/.cache \
&& rm -rf /tmp/*
COPY build/ /var/www/wechat-mp/
CMD node app.js</pre>