记一次录音转写项目经历中的技术点

最近做了一个录音转写的项目,其中有一些自认为有意思的技术点,在此记录一下。

实时通话中的录音推流

此次项目中的部分场景是,坐席与客户通话时,要将通话的内容在网页端进行展示。如果是通话完成后,将对话内容展示出来,可以直接参照 Azure 的官方教程中的示例,链接。教程中,默认的方法就是将整个文件推送上去,无脑CV即可。

通话完成进行录音识别,语音转文字,可能会遇到的难点有:

录音文件与转写服务不在同一个服务器

此时,需要在自身的服务端实现一个录音下载的 stream 接口。转写服务的客户端,调用这个接口之后,可以选择将文件暂存到本地,或者直接将下载流转到 azure 的转写客户端。关键代码如下:

服务端接口:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取录音文件
const getRecord = (req, res) => {
const { recordId } = req.query;
const recordPath = path.resolve(__dirname, 'record', `${recordId}.wav`);

res.writeHead(200, {
'Content-Type': 'audio/wav',
'Content-Length': fs.statSync(recordPath).size
});

fs.createReadStream(recordPath).pipe(res);
};

转写服务客户端,同时要考虑到链接会被重定向,主要使用以下两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function sendGetRequest(url, redirectTimes = 0) {
redirectTimes += 1;

return new Promise((resolve, reject) => {
if (redirectTimes > 10) {
return reject(new Error(`HTTP request Redirects exceeded, ${redirectTimes}`));
}

const req = http.get(url, { timeout: 3000 }, (res) => {
if (res.statusCode === 302) {
const redirectUrl = res.headers.location;
console.log('Received 302 Found, redirecting to:', redirectUrl);
return resolve(sendGetRequest(redirectUrl));
} else if (res.statusCode === 200) {
resolve(res);
} else {
reject(new Error(`HTTP request failed with status code ${res.statusCode}`));
}
});

req.on('error', (err) => {
reject(err);
});
});
}

async function getRemoteFile(file) {
let url = new URL(`/some-api`, host);
let filePath = path.join(__dirname, '暂存路径');
let writeStream = fs.createWriteStream(filePath);
let stream = await sendGetRequest(url.toString());

stream.pipe(writeStream);

return new Promise((resolve, reject) => {
writeStream.on('error', (err) => {
return reject(err);
});

writeStream.on('close', () => {
return resolve(filePath);
});
});
}

按照此链接中的方法,封装出一个接收文件输入流的转写函数 readStreamPush,将上面的文件流送入即可。

录音文件增量读取

因为是实时转写的,所以需要不断的读取在改变大小的文件。在node中,可以很方便的实现这一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
let watcher = fs.watch(file, (eventType, filename) => {
if (eventType === 'change') {
this.readIncrementalData(file, (err, buffer, done) => {
if (err) {
this.emit('error', err);
}
if (buffer) {
readStreamPush(buffer);
}
if (err || done) {
this.emit('end');

watcher.close();
pushStream.close();
readStreamPush();

if (timeout) {
clearTimeout(timeout);
return;
}
}

if (timeout) {
clearTimeout(timeout);
}

timeout = setTimeout(() => {
watcher.close();
pushStream.close();
readStreamPush();
}, 60 * 1000);
});
}
});

let lastPosition = 0; // 记录上次读取的位置

function readIncrementalData(filePath, callback) {
fs.open(filePath, 'r', (err, fd) => {
if (err) {
return callback(err);
}

// 定位到上次读取的位置
fs.fstat(fd, (err, stats) => {
if (err) {
return callback(err);
}

const currentPosition = stats.size;
const bufferLength = currentPosition - lastPosition;

if (bufferLength > 0) {
const buffer = Buffer.alloc(bufferLength);
fs.read(fd, buffer, 0, bufferLength, lastPosition, (err, bytesRead, buffer) => {
if (err) {
return callback(err);
}
// 处理读取到的内容
callback(null, buffer);

lastPosition = currentPosition; // 更新上次读取的位置

fs.close(fd);
});
} else {
callback(null, null, true);
fs.close(fd);
}
});
});
}

这里加了一个定时,如果一分钟后文件大小不再改变,则视为文件读取完毕。

格式转换

此时还会遇到的问题是,转写结果不准确,转写结果偏移量也不准确。经排查,推送的录音文件格式与 azure 要求的不符。

要求是 WAV audio files, including 16-bit, mono PCM, 8 kHz, and 16 kHz.

那就还需要将上面的文件流进行格式转换,再送去推流。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const ffmpeg = require('fluent-ffmpeg');
const { Readable } = require('stream');

function createConversionStream(callback) {
let readStream = new Readable();
readStream._read = () => { };

let command = ffmpeg()
.input(readStream)
.audioFrequency(16000)
.audioChannels(1)
.audioCodec('pcm_s16le')
.on('error', (err) => {
callback(err);
})
.on('end', () => {
callback(null, null);
})
.format('wav');

command
.pipe()
.on('data', data => {
callback(null, data);
});

return function (buffer) {
if (buffer) {
readStream.push(buffer);
} else {
if (command && typeof command.kill === 'function') {
command.kill();
}
}
};
}

module.exports = {
createConversionStream,
}

上面的代码可视作返回输出流,直接送入封装的转写服务即可。

转写结果展示

在页面展示时,实现了一个类似于微信对话框的样式,可以直接用于展示转写结果,一左一右,坐席与客户。因为项目中有知识库,如果对话中涉及到了知识库设置的关键词,需要将关键词高亮显示,同时点击时要弹出知识库的内容。此处参考了类似于B站评论区的关键词的高亮样式。词语变色,右上角加了放大镜。

将转写结果存入 elasticsearch 中,可以很方便的实现这一点。

首先在前端实现一个自定义标签<search-keywords>,完成样式和点击的功能。

在实时转写的查询中,加入知识库的关键字作为匹配条件,使用 elasticsearch 的 match_phrase 查询。给匹配到的关键词加上标签,返回到前端时就能渲染了。

1
2
3
4
5
6
7
8
9
10
11
12
query.body.highlight = {
"fields": {
[highlightField]: {
"type": "unified",
"number_of_fragments": 0
}
},
"pre_tags": ["<search-keywords>"],
"post_tags": ["</search-keywords>"],
"require_field_match": true,
"encoder": "html"
};

本文为个人原创,转载请署名且注明出处。

Hello Again

时隔多年,终于把废弃的博客救回来了。

找了个简洁的博客主题,不搞花里胡哨。

争取以后有时间多更新些新的内容上来。

:)

关于拓展NodeMediaServer以支持JT1078

Jul. 13, 2019

可能与最近几月坚持跑步有关,头脑突然灵活了很多。

前几日闲来无事摸鱼时,突然就翻回到了NodeMediaServer(NMS)的代码。从服务启动,到视频的解析推流,大致三到四个文件,脉络清晰。NMS的文件结构给了我很大的启发。我突然就想到,完全可以按照NMS的结构,把JT1078的解析也整合进去。

想到去年年底,第一次接触JT1078,视频拉流推流这些概念的时候,完全是一脸懵比。当时准备在NMS上改造,也是在视频推流之后,在NMS解析视频包的位置进行后续处理。甚至研究了一段时间的RTMP握手,由于设备推流不是RTMP协议,在代码里还要判断,如果是设备推流过来的,要跳过RTMP握手阶段,再推流出去。现在回想起来,甚是痛苦。好在现在知道了如何使用FFmpeg作为推流工具,使得这一功能实现。

今日,我仿照NMS的结构,和前几篇文章提到的FFmpeg命令,主要以FFmpeg推流实现的JT1078解析代码推到了仓库里。明显这不是最优的方案。其中一个很大的问题就是一个通道就要启动三个子进程(视频,音频和合并音视频),一般每台设备会有六个通道,观看一个设备的视频直播时就要启动大量的FFmpeg,相当耗费资源。而且这些子进程管理起来比较麻烦,有可能随时某一个在收不到数据时的进程关闭,就会出现 EPIPE 错误。而我理想中的最佳方案并不是这样的。最初,我尝试将设备推流过来的视频和音频数据,分别使用NMS原代码中的RTMP封包函数处理,送到播放者的socket中去,但是并没有成功。尝试几轮,服务端没有报错,看起来成功了,但是播放端并没有数据。未找到原因在哪,就暂时转换思路,使用FFmpeg来实现了。

后来又想到,直接使用NMS的封包函数是不行的。视频数据倒是不需要额外处理,音频数据是G7xx格式的,RTMP并不支持,所以需要转码。看来代码层面的封包和解码还需要研究一段时间。

唉!现在是不能够用代码直接封装音视频包,只得使用FFmpeg命令。我要是懂得音视频解封装,还能吃这个亏?

Jul. 16, 2019

今日,研究了一下FFmpeg的命令,想到可以使用两条输入流(视频和音频)转化成一条输出流(RTMP)的方式实现转码和推流。这样就能够不创造出上一方案中多出来的视频和音频子进程了。在将设备的视频和音频流数据,分别存储下来后,使用FFmpeg命令推流

1
ffmpeg -i test.264 -i test.mp3 -map 0:v -map 1:a -c:v copy -c:a copy -f flv rtmp://xxxx:1935/live/stream_xxxx

命令执行成功,证明了这种方案可能行。于是,我将代码改造为,先将数据缓存,然后使用FFmpeg推流。谁知,实际情况是,缓存数据太小,FFmpeg进程启动后,瞬间就完成了推流,然后关闭了进程。造成的现象就是程序完全没有在(持续)推流。如果FFmpeg随时都要启动的话,有可能会遇到,总将文件从头开始推流的问题。遂暂时搁置了这种方案。

根据上一方案存在的问题,又想到了启动子进程后,可以使用写入 stdin 的方式,将数据写入FFmpeg的进程中。但是并没有找到NodeJs写入两条输入流到一个子进程中去的方法。就算是将视频流利用管道符串接到音频流的转码进程中,还是需要有两条输入流。看来这种方案暂时也不行了。

无意中翻到了 fluent-ffmpeg 这个库,好像有支持两条输入流的接口,待有时间再测试。

Dec. 31, 2019

万万没想到,在2020以前我能将上面的问题解决。 先忘记fluent-ffmpeg这件事吧
注: 以下内容适用于Linux系统
解决方案仍然是,将视频和音频分流后存储为文件,不同的是文件类别为pipe文件,即命名管道文件(Named Pipe).它遵循先进先出原则(FIFO),可以像普通文件一样管理。
可以使用以下命令创建管道文件:

1
2
$ mkfifo /tmp/one.pipe
$ mkfifo /tmp/two.pipe

然后将程序分离出的视频数据和音频数据像写文件一样写入即可,ffmpeg的进程则从这两个文件中读取数据,再转换成rtmp推流发送出去。
ffmpeg命令如下:

1
$ ffmpeg -loglevel panic -probesize 32 -re -r 16 -f h264 -i /tmp/one.pipe -f mp3 -i /tmp/two.pipe -map 0:v -map 1:a -c:v copy -c:a aac -strict -2 -preset ultrafast -tune zerolatency -f flv rtmp://xxxx:1935/live/stream_xxxx

读入的文件需要指定格式,如h264和mp3(在上一步中将g726转为了mp3).
具体代码实现见我此次提交 commit

JT1078协议开发之双向对讲后记

前言

上一篇大致介绍了一下实时视频的开发流程,这一篇所要介绍的双向对讲算是上一篇内容的小进阶。由 JT/T 1078协议(以下简称1078)中介绍可知,双向对讲的数据传输方式也是在 5.5.3 表19中定义的。下达双向对讲指令通过改变修改 0x9101 消息ID中的参数来实现,本篇不再赘述。功能实现思路与上一篇相同,使用 nodejsFFmpeg 实现。测试系统环境为 Linux .

准备

见上一篇

流程

实时视频流程大致如下:

1
2
3
4
5
6
7
8
9
participant 网页
participant 服务器
participant 设备

网页 ->> 设备: 下达双向对讲指令
设备 ->> 服务器: 推送音频数据
服务器 ->> 网页: 转换音频格式推流至网页,并通知开启网页麦克风
网页 ->> 服务器: 采集音频数据并推送
服务器 ->> 设备: 将网页端音频转码,发送至设备

网页采集音频

浏览器利用麦克风采集麦克风的方法,网上的代码有很多,方法也是如出一辙。
下面直接贴出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
var leftchannel = [];
var rightchannel = [];
var recorder = null;
var recording = false;
var recordingLength = 0;
var volume = null;
var audioInput = null;
var sampleRate = null;
var audioContext = null;
var context = null;
var outputElement = document.getElementById('output'); // 用于提示当前录音状态

if (!navigator.getUserMedia)
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia;

if (navigator.getUserMedia) {
navigator.getUserMedia({ audio: true }, success, function (e) {
alert('Error capturing audio.');
});
} else {
alert('getUserMedia not supported in this browser.');
};

function interleave(leftChannel, rightChannel) {
var length = leftChannel.length + rightChannel.length;
var result = new Float32Array(length);

var inputIndex = 0;

for (var index = 0; index < length;) {
result[index++] = leftChannel[inputIndex];
result[index++] = rightChannel[inputIndex];
inputIndex++;
}
return result;
}

function mergeBuffers(channelBuffer, recordingLength) {
var result = new Float32Array(recordingLength);
var offset = 0;
var lng = channelBuffer.length;
for (var i = 0; i < lng; i++) {
var buffer = channelBuffer[i];
result.set(buffer, offset);
offset += buffer.length;
}
return result;
}

function writeUTFBytes(view, offset, string) {
var lng = string.length;
for (var i = 0; i < lng; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}

function success(e) {
console.log('Record Init Succcess!');

audioContext = window.AudioContext || window.webkitAudioContext;
context = new audioContext();
sampleRate = context.sampleRate; // 44100
volume = context.createGain();
audioInput = context.createMediaStreamSource(e);

audioInput.connect(volume);

var bufferSize = 2048;
recorder = context.createScriptProcessor(bufferSize, 2, 2);

recorder.onaudioprocess = function (e) {
if (!recording) return;
var left = e.inputBuffer.getChannelData(0);
var right = e.inputBuffer.getChannelData(1);
leftchannel.push(new Float32Array(left));
rightchannel.push(new Float32Array(right));
recordingLength += bufferSize;
console.log('recording...');
combineAudio([new Float32Array(left)], [new Float32Array(right)], bufferSize, sampleRate);
}

volume.connect(recorder);
recorder.connect(context.destination);
}

function combineAudio(leftc, rightc, bfSize, sampleRate) {
var leftBuffer = mergeBuffers(leftc, bfSize);
var rightBuffer = mergeBuffers(rightc, bfSize);
var interleaved = interleave(leftBuffer, rightBuffer);
var dataLen = interleaved.length * 2;
var buffer, view, index = 0;

buffer = new ArrayBuffer(dataLen);
view = new DataView(buffer);

var lng = interleaved.length;
var volume = 1;
for (var i = 0; i < lng; i++) {
view.setInt16(index, interleaved[i] * (0x7FFF * volume), true);
index += 2;
}

var blob = new Blob([view], { type: 'audio/wave' }); // 最终音频数据
// 将blob写入 websocket, 略
}

上述代码主要思路为通过改变全局变量 recording 控制录音,这个可以使用 button 元素实现。开启录音后,浏览器采集左右声道的音频数据,将数据存入Buffer后,合并两通道数据然后通过 DataViewBlob 包装后的数据发送至服务器。为了保证实时性,我这里使用的是 websocket 发送至后台。上述代码得到的音频数据为 PCMS16LE 原始数据,默认采样率为44.1k,所以体积比较巨大。可以通过减少声道数量和降低采样率,或者直接在浏览器端重新编码来减少体积。

音频转码

音频转码涉及两个部分,一是将网页端采集的音频数据转换成设备支持的格式发送给设备;二是将设备端发送的音频数据转成网页端支持的格式进行播放。
设备支持什么格式呢?由于设备的不同,可能初始默认的音频格式都不尽相同。最方便的肯定是直接操作设备进行查看了。还可以在下达双向对讲指令后,解析RTP包参照1078 表12 进行查看。我手里这台设备的音频格式是 G726LE.
首先进行网页端播放设备音频流的实现。有了上一篇文章的经验,音视频服务器和网页端播放器都是准备好的,一下子省去了很多的工作,只需要将设备音频数据转码发送给音视频服务器即可。然后,网页端从音视频服务器提供的地址拉流就能进行播放了。看起来很简单有木有,突然有点小兴奋。
接下来的思路与视频处理相似,使用子进程开启 FFmpeg 进行转码并推流。由于推流要使用 flv 格式的 RTMP 流,而 flv 支持的音频格式是有限的(点击查看),光是进行音频转码就占用了一个FFmpeg子进程,所以还需要另一个进程将转码后的数据推流出去。听起来很麻烦,但FFmpeg支持数据流处理,所以实现起来非常简单,只需要将两个子进程的STDIN, STDOUT pipe 起来就行了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const { spawn } = require('child_process');
const FFMPEG = '/usr/bin/ffmpeg';

var cmds1 = [
'-loglevel', 'panic',
'-probesize', '32',
'-f', 'g726le',
'-code_size', '5',
'-ar', '8000',
'-ac', '1',
'-i', '-',
'-ar', '44100',
'-acodec', 'libmp3lame',
'-f', 'mp3',
'pipe:1'
];
var cmds2 = [
'-i', '-',
'-vn',
'-c', 'copy',
'-f', 'flv',
rtmpUrl
];

var child1 = spawn(FFMPEG, cmds1);
var child2 = spawn(FFMPEG, cmds2);

child1.stdin.write(data);
child1.stdout.pipe(child2.stdin);

这样,网页端播放器就能使用 RTMP 链接播放设备的音频数据了。
回来处理网页端到设备端的部分,思路也清晰了起来,只需将音频数据写到设备就行了。已经知道了设备支持的编码格式是 G726LE,所以首先需要将音频转码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { spawn } = require('child_process');
const FFMPEG = '/usr/bin/ffmpeg';

var cmds = [
'-loglevel', 'panic',
'-f', 's16le',
'-ar', '44.1k',
'-ac', '2',
'-i', '-',
'-acodec', 'g726le',
'-code_size', '5',
'-ar', '8k',
'-ac', '1',
'-f', 'g726le',
'pipe:1'
];
var _process = spawn(FFMPEG, cmds);

开启子进程后,将网页端数据写入进程STDIN,再将STDOUT数据写入设备连至服务器的TCP连接即可。因为流程中,是先下达指令,设备连接上来后,再进行网页端数据写入,设备的连接是很容易获取到的。

总结

实时视频和双向对讲的实现思路大体上是一致,区别在FFmpeg的参数不同,多开启了几个进程而已。不由得感叹FFmpeg的强大。前后两篇文章中,涉及到业务的代码都是一笔带过,而主要列举了FFmpeg的命令。不要小看这简单的几行命令,对于从没接触过FFmpeg和音视频知识几乎为零的我来说,花费了大量的时间来搜索和尝试。相比之下,业务上码代码花费的时间更少。业务代码中也有很多值得注意的点,如设备连接和断开时,FFmpeg进程的管理;FFmpeg进程写入时,EPIPE的处理(管理各个子进程的事件回调);双向对讲状态的共享(Redis实现)等等。经过这一项目的一番折腾,也算是了解一些音视频方面的知识,也对这方面有了一番兴趣。打算借着没有减退的热度开个音视频相关的小项目,希望自己不要弃坑。

JT1078协议开发之实时视频后记

前言

前几个月一直在进行JT/T 1078协议(以下简称1078)相关项目的开发,其中涉及各种音视频协议和网络协议,还有音视频服务器和处理软件的知识。从一开始处于知识盲区的我,一路摸爬滚打,google和阅读书籍,挖坑填坑,总算是把项目需要的功能给研究出来了。当然,1078本身描述的功能很多,全部实现需要大量的人力和时间,一篇文章也不可能讲完。所以,打算用两篇文章主要记录一下实时视频和双向对讲的实现方法。这一篇为实时视频,双向对讲放在下一篇。功能皆使用 nodejsFFmpeg 实现,系统环境为 Linux .

准备

项目需求简单地说就是,有一台支持1078协议的设备,接收特定指令后,通过协议内容将音视频数据发送至服务器(以下简称推流),服务器再将音视频数据转发出去,使得客户端或者网页播放器能够收看到设备发送的数据(以下简称拉流),以达到实时视频的效果。后面功能的实现皆为网页端操作。

所以至少要准备:

  • 1078协议文档
  • 1078协议设备
  • 音视频服务器
  • 播放器

流程

实时视频流程大致如下:

1
2
3
4
5
6
7
participant 网页
participant 服务器
participant 设备

网页 ->> 设备: 下达推送视频指令
设备 ->> 服务器: 推送视频数据
服务器 ->> 网页: 转换为网页支持的格式

音视频服务器与解析

下达推送指令部分可按照协议要求的格式轻松搞定,主要问题在于如何将设备的音视频数据推流至服务器。服务器再转换为网页端能够拉流播放的格式。说到视频直播,推流拉流,首先想到的是RTMP。在搜索引擎中搜一搜,支持RTMP的音视频服务器,无论是开源还是闭源,都有很多。首先找到一个部署方便的,放在服务器上待命。
部署完成之后,可以使用FFmpeg推送本地文件至服务器测试好不好用。

FFmpeg命令如下:

1
ffmpeg -re -i localFile.mp4 -c copy -f flv rtmp://server:1935/live/streamName

RTMP链接后部分可能根据服务器有所不同。端口一般为1935.
播放RTMP链接,可以使用FFmpeg内置的ffplay,或者其他在线播放网站即可。

由1078文档内容可知,设备的音视频传输方式,定义在 5.5.3 部分。

实时音视频流数据的传输参考RTP协议,使用UDP或TCP承载。

可知,设备推流并不是RTMP协议,而是魔改的RTP协议,具体内容在表19中。这和服务器接收的数据协议不同,所以需要将设备推流转换为服务器能够接收的协议格式。
第一次搞这种东西,只看文档一头雾水,没别的办法,搭建一个TCP服务,让设备把数据发过来,验证数据格式是否与文档中描述的一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const net = require('net');
const HOST = 'xx', PORT = 'xx'; // 在下发指令中指定
const server = net.createServer(socket => {
console.log('TCP Server Created!');

socket.on('error', err => {
console.log('TCP Server Error ==', err);
});

socket.on('end', _ => {
console.log('TCP Server Closed.');
});

socket.setKeepAlive(true, 2000);
socket.setTimeout(10000, _ => {
socket.end('TCP Socket Broken.');
});
});
server.listen(PORT, HOST, _ => {
console.log(`Server Start Successfully, Listen on ${PORT}.`);
});
server.on('connection', socket => {
var { remoteAddress } = socket, { address, port } = socket.address();
console.log('Address is ', address, '. Port is', port, '. remoteAddress is ', remoteAddress);

socket.on('data', data => {
// console.log('TCP ON DATA');
});

socket.on('end', _ => {
console.log('Bye');
});
});

设备推流后会触发socket的on data 事件,由于data是Buffer,所以需要把data转换成 16进制 的数据才能对照文档分析。转换完成后,可以大致看到一些规律,与文档中描述的一致。一个data变量可能包含多个以 0x30 0x31 0x63 0x64 开头的数据,对应文档中的帧头标识。有没有可能在负载数据中也出现帧头呢,那就再往后取一个字节,降低概率。可以观察到第4至第5字节,都是 0x81 .具体含义可以看 RFC 3550.每个RTP包除了前30字节(也有可能少于30)的数据描述外,就是媒体数据payload了。
接下来要做的是,从TCP数据中分离出每个RTP包。TCP是基于流式传输的(我真的很讨厌“粘包”这个词),每个data不一定以帧头开始,或者结束,而且中间可能包含多个RTP包。但是已经知道了帧头一定是 0x30 0x31 0x63 0x64 0x81 , 所以可以用帧头来切分TCP数据。再将切分出来的RTP包传入到下一个函数中单独处理。这部分处理很简单,不再贴代码,需要注意的地方就是如何将两个TCP data中的RTP包数据组合再传入函数。
先假定现在是最理想的情况,即不需要考虑RTP包乱序,视频帧乱序的情况,以这种前提将payload存储下来看看能否正常播放。当然,上面的再取一个 0x81 也是在这种前提下考虑的。
接下来就是要分析RTP包的数据了。完全对照文档中的表19按字节读取即可。网络传输使用的是 大端模式 ,nodejs中使用的函数主要为以下几个:

1
2
3
4
buf.slice([start[, end]])
buf.readInt8(offset)
buf.readInt16BE(offset)
buf.readInt32BE(offset)

解析完成后,可以看到当前RTP包所承载的数据信息。如包序号,SIM卡号,通道号,数据类型,分包标记等。原子包不做处理,分包需要将后续的包组合成一个数据。从第5字节中的数据可知,当前包负载中的媒体类型,解析后对应表12查得可知,音频格式为 G.726,视频格式为 H.264.不同设备的格式可能有所不同。将音频包和视频包分别存储为音频和视频文件,再使用软件播放验证前面解析程序的正确性。全部正确的话,这些文件都是可以正常播放的。
接下来就需要想办法如何把这些数据推流到音视频服务器了。服务器接收的格式为RTMP流,需要把从RTP分离出的媒体数据转化为RTMP流推到服务器。由于本人还不能直接用nodejs撸出一个转码和推流服务的程序出来,接下来的工作便交给了 FFmpeg 处理。
先来看一下如何处理视频数据,用FFmpeg将本地文件推送至RTMP服务器,查官方文档可知,得到下面的命令:

1
2
3
4
5
6
7
8
9
10
ffmpeg -loglevel panic \
-probesize 32 \
-re \
-r 16 \
-i localfile.h264 \
-c:v libx264 \
-preset ultrafast \
-tune zerolatency \
-profile:v baseline \
-f flv "rtmp://xxx"

上面这条语句不止能推送文件,还是最终的优化版。
解释一下部分选项:

  • -loglevel panic 用于屏蔽ffmpeg的输出内容
  • -probesize 32 用于降低ffmpeg转换延迟
  • -r 16 设置帧率,需要与设备端一致,否则会出现播放速度太快的问题
  • -f flv “” 输出rtmp链接,这里需要使用SIM卡号和通道号组合成特定链接

有了这条命令,就该想办法如何把RTP中的payload输入到ffmpeg了。
ffmpeg支持从STDIN输入的数据,所以只需要将ffmpeg执行命令放入nodejs的child process子进程中,然后把payload数据写入该子进程的STDIN即可,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { spawn } = require('child_process');
const FFMPEG = '/usr/bin/ffmpeg';

var cmds = [
'-loglevel', 'panic',
'-probesize', '32',
'-re',
'-r', '16',
'-i', '-',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-tune', 'zerolatency',
'-profile:v', 'baseline',
'-f', 'flv',
rtmpUrl
];
var child = spawn(FFMPEG, cmds);
child.stdin.write(data);

区别在于,将本地文件的名称换成了 - .ffmpeg会在子进程中将payload数据转化成rtmp推流到服务器。完成这一部分的代码之后,可以先尝试着将数据推流到音视频服务器,然后找一个能在线看rtmp的网站进行测试。程序无误的情况下,可以看到直播数据。在单台设备全通道的情况下,可以做到延迟 2-3s .说明上面的“大胆”假设是正确的,一下子感觉轻松了不少:)

再接下来需要进一步完善程序,如:

  • 设备收到停止视频推送指令后,需要关闭ffmpeg的子进程
  • 多设备,多通道情况下的ffmpeg子进程管理

这就与1078协议开发无关了,考验写程序功底的时候到了,本文不再赘述。

音频部分涉及到转码,留在与下一篇双向对讲功能一起说。

网页端

网页端无论是Flash还是H5的视频播放器有很多,翻翻文档API就可以查到如何播放指定链接的视频。如果音视频服务器不仅能提供rtmp流,还能提供flv流,网页播放的实现可以更加灵活。

后记

1078协议实时视频的开发,说难不难,说简单不简单。它难就难在网上的资料居然如此之少,完全需要靠自己摸索验证。而且在开发验证的过程中,很容易走弯路。开发完成后,回头再看,发现只是需要按文档读取数据而已,并没有涉及音视频领域更深一层的知识。
本文并没有完全使用nodejs进行转码和推送的实现,而是使用了ffmpeg作为子进程进行推流。恕本人学识有限,音视频知识的学习任重而道远,暂时只能以这样的“笨”方法实现功能需求。新的学习计划已提上日程,希望在以后的日子里能够进一步改进。

2018都做了些什么

不要高估一天能做到的事,也不要低估一年能做到的事。

我这个人是拒绝鸡汤的。偶然在V2里看到这句话,思考了一下,也确实有一些道理。回想到去年此时的自己,怎么也想不到会学习了这么多的新技能。每天学习一点点,日积月累,真的很重要。

回想这一年:

  • 印象最深的一本书,是《学会提问》。书本身很小巧,几个小时就能读完,但是收获的内容可以说是受益终生。作者从提问的意义,到提问分类,再到优质提问的方法,阐述了学会做优质的提问是多么的重要。问题也可分为抛给对方还是抛给自己的。抛给对方的问题,能够带来有益的思考,还能解决自己的迷惑。而抛给自己的优质提问是有力的内驱力。
  • 印象最深的影视剧,是《重版出来!》。有一点浮夸风的职场剧,毕竟是漫改。看罢,真是感觉热血到不行。也了解到了,一部漫画作品从创作到连载的艰辛。如果能够依靠自己的爱好赚钱,真是既痛苦又快乐的事情。以至于,看完剧的我买了几本《超级漫画素描技法》,然而并没有怎么看,放到书架上吃灰中(摊手)。

回到上班摸鱼,除了几个从部署到完成业务的与深度学习相关的功能,还是在前端页面和后端增删改查之中打转。深度学习并没有深度学习,前后端相比以前熟悉了很多,四舍五入也算是进步了吧。在业余时间,学习了下docker的使用,这绝对是这一年最大的收获了。在部署服务和测试功能上,都有很大的帮助,绝对是早学早受益。

夏季之前,也尝试了下Unity3D.利用了半个月左右的下班后时间,制作出了极其简陋的第三人称射击游戏。游戏拥有基本的界面和操作,敌人不能动,但是能够随机地点重生。说的好像有点roguelike呢。深刻体会到了游戏制作的艰辛,由衷地钦佩能够包揽美术、音乐和策划的独立制作人。

这一年一直坚持下来的就是日语的学习了,受影视音乐文化的影响,也保持着高涨的热情。虽然进步缓慢,但督促自己能够坚持下去。年底接手了与音视频相关的项目,接触不少新知识,C/C++的学习也提上了日程(虽然被《C++ Primer Plus》的厚度吓到劝退)。希望在新的一年里,珍惜时间,努力学习,想要放弃的时候,想想为什么要开始。

给console.log一点颜色看看

开发中少不了使用console.log进行调试,那么如何使打印出的内容具有颜色呢?

首先要说明的是,这里指的打印是在命令行中进行输出的,而不是在chrome控制台中。

答案是,把console.log()第一个参数设置为ANSI转义码即可, 第二个参数为需要打印的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 像这样
console.log("\x1b[31m", "I'm green.");

// 当然,聪明而又懒惰的我们不会一个一个去敲这些字符,真是光看看就觉得麻烦啊,So...
const FgRed = "\x1b[31m";
console.log(FgRed, "I'm red.");

// 真是雕虫小技啊:P
// 这里还有有更多的颜色
Reset = "\x1b[0m",
Bright = "\x1b[1m",
Dim = "\x1b[2m",
Underscore = "\x1b[4m",
Blink = "\x1b[5m",
Reverse = "\x1b[7m",
Hidden = "\x1b[8m",
FgBlack = "\x1b[30m",
FgRed = "\x1b[31m",
FgGreen = "\x1b[32m",
FgYellow = "\x1b[33m",
FgBlue = "\x1b[34m",
FgMagenta = "\x1b[35m",
FgCyan = "\x1b[36m",
FgWhite = "\x1b[37m",
BgBlack = "\x1b[40m",
BgRed = "\x1b[41m",
BgGreen = "\x1b[42m",
BgYellow = "\x1b[43m",
BgBlue = "\x1b[44m",
BgMagenta = "\x1b[45m",
BgCyan = "\x1b[46m",
BgWhite = "\x1b[47m";

拓展阅读:ANSI escape code

JS野生程序员进修指南

基本

缩进
4个空格,而不是一个制表符

结尾
分号结尾

行长度
80个字符

换行
符号结尾,第二行追加2单位(8字符)缩进

空行

  • 方法之间
  • 方法中局部变量和第一条语句之间
  • 注释之前
  • 逻辑片段之间

命名

  • Camel Case
  • 变量前缀为名词
  • 函数前缀为动词
  • 表数量 count, length, size
  • 循环体 i, j, k
  • 常量使用大写字母,下划线分隔单词
  • 构造函数使用Pascal Case

函数名参考

动词 含义
can 返回布尔值
has 返回布尔值
is 返回布尔值
get 返回非布尔值
set 保存一个值

直接量

  • 字符串 单引号、双引号皆可
  • 数字,不使用八进制;不以小数点结尾

null

  • 对象占位符
  • 不要 检测是否传入某个参数
  • 不要 检测一个未初始化的变量

undefined
未被初始化的变量初始值

对象直接量

1
2
3
4
5
6
7
8
9
10
//不好的写法
var book = new Object();
book.title = "JavaScript";
book.author = "Mike";

//好的写法
var book = {
title: "JavaScript",
author: "Mike"
}

数组直接量

1
2
3
4
5
6
7
//不好的写法
var colors = new Array("red", "blue", "green");
var numbers = new Array(1, 2, 3);

//好的写法
var colors = ["red", "blue", "green"];
var numbers = [1, 2, 3];

注释

单行注释
//something

  • 独占一行,解释下一行,注释前留一空行,缩进与下一行保持一致
  • 尾部注释与代码保持一单位缩进,不应超过最大长度限制

多行注释

1
2
3
4
/*
* 第一行注释
* 第二行注释
*/

注释时机

  • 难于理解的代码
  • 可能被理解错误的代码
  • 浏览器兼容性

文档注释

1
2
3
/**
这是一个文档注释
**/

语句和表达式

基本

1
2
3
4
5
6
7
8
9
10
11
12
//好的写法
if (condition) {
doSomething();
}

//不好的写法
if (condition)
doSomething();

if (condition) doSomething();

if (condition) { doSomething(); }

缩进应对齐
所有块语句都应使用花括号

  • if
  • for
  • while
  • do…while…
  • try…catch…finally

花括号对齐方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//好的写法
if (condition) {
doSomething();
} else {
doSomethingElse();
}

//不好的写法
if (condition)
{
doSomething();
}
else
{
doSomethingElse();
}

块语句间隔

1
2
3
4
5
6
7
8
9
10
11
12
13
//好的写法
if (condition) {
doSomething();
}

//不好的写法
if(condition){
doSomething();
}

if ( condition ) {
doSomething();
}

switch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
switch(condition) {
case "first":
//代码
break;

case "second":
//代码
break;

case "third":
//代码
break;

default:
//代码
}

with
禁用

for
避免使用continue

for-in
不应遍历数组

变量、函数和运算符

变量声明

  • 注意提升机制
1
2
3
4
5
6
7
8
9
10
11
//好的写法
function doSomethingWithItems(items) {
var value = 10,
result = value + 10,
i,
len;

for (i=0, len=items.length; i < len; i++) {
doSomething(items[i]);
}
}

函数声明

  • 先定义后使用
  • 不应在块语句内定义
  • 函数名与左括号间无空格

立即调用函数

1
2
3
4
5
6
7
8
//好的写法
var value = (function() {
//函数体

return {
message: "Done"
}
}());

严格模式

  • 应在函数体内使用,而非全局

相等

  • 应使用 ‘===’, ‘!==’, 而不是 ‘==’, ‘!=’

eval()

  • 避免使用 eval(), Function
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //不好的写法
    var myfunc = new Function("alert('Hi!')");

    setTimeout("document.body.style.background='red'", 50);

    setInterval("document.title = 'It is now'" + (new Date()), 1000);

    //而应使用匿名函数代替
    setTimeout(function() {
    document.body.style.background='red';
    }, 50);

原始包装类型

1
2
3
4
//不好的写法
var name = new String("Mike");
var author = new Boolean(ture);
var count = new Number(10);

码农札记

Git

日常操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# 获取远端代码
git fetch upstream xxxx

# 将远端代码合并到本地
git merge upstream/xxxx

# 获取并合并代码
git pull

# 查看不同(工作区与暂存区)
git diff

# 查看工作区修改的文件
git status

# 放弃工作区的修改
git checkout

# 将修改的文件提交到暂存区
git add

# 提交更改
git commit -m "# 注释"

# 提交到远端
git push origin xxxx

# 查看提交日志
git log (--pretty=oneline)

# 版本回退
git reset --hard HEAD^ # 上一个版本
git reset --hard HEAD^^ # 上上一个版本
git reset --hard HEAD~20 # 上20个版本

# 回到未来
git reflog
git reset

# 删除文件
git rm
git commit

# 合并一个commit
git cherry-pick [commitId]

# 修改上一次commit注释
git commit --amend -m '# 新的注释'

# 显示将要删除的文件
git clean -n

# 删除没有track的文件,不包含.gitignore
git clean -f

# 删除指定路径下没有track的文件
git clean -f <path>

# 删除当前目录下没有track的文件和文件夹
git clean -fd

# 暂存更改
git stash

# 弹出暂存
git stash pop

#### 远程仓库
# 创建SSH KEY
# 用户目录 .ssh/
# id_rsa 私钥
# id_rsa.pub 公钥
ssh-keygen -t rsa -C "my@email.com"

# 在Github上 Add SSH Key, 粘贴公钥内容

# 克隆远端代码
git clone some_url

进阶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 创建并切换到分支
git checkout -b dev

# 等同于
git branch dev
git checkout dev

# 查看分支
git branch

# 合并分支
git merge

# 删除分支
git branch -d

# 查看分支合并图
git log --graph

# 分支策略
git merge --no-ff -m "注释" dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Bug分支
# 暂存修改
git stash

# 回到master,并创建新分支,修复后提交
git checkout master
git checkout -b issue-101
git commit -m "fix-issue-101"

# 合并bug分支并删除
git checkout master
git merge --no-ff -m "m-merge-issue-101" issue-101
git branch -d issue-101

# 将修复bug后的master合并到dev
git checkout dev
git merge --no-ff -m "dev-merge-m" master

# 弹出暂存的修改
git stash pop

# 修复冲突
git commit -m "fixconflict & append something"

# 完成后提交
git checkout master
git merge --no-ff -m "m-merge-dev" dev
git branch -d dev

标签管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 创建标签,默认HEAD,也可指定commit id
git tag <name>

# 指定标签信息
git tag -a <tagname> -m "message"

# PGP签名标签
git tag -s <tagname> -m "message"

# 查看所有标签
git tag

# 推送本地标签
git push origin <tagname>

# 推送全部本地未推送的标签
git push origin --tags

# 删除一个本地标签
git tag -d <tagname>

# 删除一个远程标签
git push origin :refs/tags/<tagname>

Linux

文件命令

命令 操作
ls 列出目录
ls -al 使用格式化列出隐藏文件
cd dir 更改目录到dir
cd 更改到home目录
pwd 显示当前目录
mkdir dir 创建目录dir
rm file 删除file
rm -r dir 删除目录dir
rm -f file 强制删除file
rm -rf dir 强制删除目录dir
cp file1 file2 将file1复制到file2
cp -r dir1 dir2 将file1复制到dir2,如果dir2不存在则创建它
mv file1 file2 将file1重命名或移动到file2,若file2存在则移动
ln -s file link 创建file的符号连接link
touch file 创建file
cat > file 将标准输入添加到file
more file 查看file的内容
head file 查看file的前10行
tail file 查看file的后10行
tail -f file 从后10行开始查看file的内容

进程管理

命令 操作
ps 显示当前的活动进程
top 显示所有正在运行的进程
kill pid 杀掉进程id pid
killall proc 杀掉所有名为proc的进程
bg 列出已停止或后台的作业
fg 将最近的作业带到前台
fg n 将作业n带到前台

文件权限

命令 操作
chmod octal file 更改file的权限
  • 4 读(r)
  • 2 写(w)
  • 1 执行(x)

SSH

命令 操作
ssh user@host 以user用户连接到host
ssh -p port user@host 在端口port以user用户身份连接到host
ssh-copy-id user@host 将密钥添加到host以实现无密码登录

搜索

命令 操作
grep pattern files 搜索files中匹配pattern的内容
grep -r pattern dir 递归搜索dir中匹配pattern的内容
command l grep pattern 搜索command输出中匹配pattern的内容

系统信息

命令 操作
date 显示当前日期和时间
cal 显示当月的日历
uptime 显示系统从开机到现在所运行的时间
w 显示登录的用户
whoami 查看你的当前用户名
finger user 显示user的相关信息
uname -a 显示内核信息
cat /proc/cpuinfo 查看cpu的信息
cat /proc/meminfo 查看内存信息
man command 显示command的说明手册
df 显示磁盘占用情况
du 显示目录空间占用情况
free 显示内存及交换区占用情况

压缩

命令 操作
tar cf file.tar files 创建包含files的tar文件file.tar
tar xf file.tar 从file.tar提取文件
tar czf file.tar.gz files 使用Gzip压缩创建tar文件
tar xzf file.tar.gz 使用Gzip提取tar文件
tar cjf file.tar.bz2 使用Bzip2压缩创建tar文件
tar xjf file.tar.bz2 使用Bzip2提取tar文件
gzip file 压缩file并重命名为file.gz
gzip -d file.gz 将file.gz解压缩为file

网络

命令 操作
ping host ping host并输出结果
whois domain 获取domain的whois信息
dig domain 获取domain的DNS信息
dig -x host 逆向查询host
wget file 下载file
wget -c file 断点续传

安装

从源代码安装:

1
2
3
4
5
./configure
make
make install
dpkg -i pkg.deb
rpm -Uvh pkg.rpm

快捷键

命令 操作
Ctrl + C 停止当前命令
Ctrl + Z 停止当前命令,并使用fg恢复
Ctrl + D 注销当前会话,与exit相似
Ctrl + W 删除当前行中的字
Ctrl + U 删除整行
!! 重复上次的命令
exit 注销当前会话

Composer搭建MVC框架

Composer是一个依赖管理工具,利用PSR标准和PHP命名空间的特性,构造出繁荣的PHP生态系统。

Composer是什么?

先从别处摘抄一段简介,感受一下Composer的功能。
Composer不是一个包管理器。它涉及”pachages”和”libraries”,但它在每个项目的基础上进行管理,在你项目的某个目录中(例如vendor)进行安装。默认情况下它不会在全局安装任何东西。因此,这仅仅是一个依赖管理。

这种想法并不新鲜,Composer受到了node’s npm和ruby’s bundler的强烈启发。而当时PHP下并没有类似的工具。

Composer将这样为你解决问题:

1.你有一个项目依赖于若干个库。
2.其中一些库依赖于其他库。
3.你声明你所依赖的东西。
4.Composer会找出哪个版本的包需要安装,并安装它们(将他们下载到你的项目中)。

好了,了解功能之后,lets coding!

准备工作

Composer安装

百度一下

建立项目

在一个目录下建立文件夹,名字就叫smvc(simple mvc)吧。
在文件夹下新建文件composer.json:

1
2
3
4
5
{
"require":{

}
}

在此目录下,开启命令行运行:

1
composer update

几秒钟后会生成以下文件:

1
2
3
4
5
6
7
8
9
10
11
12
|--vendor/
| |--scrapy.cfg
| | |--autoload_classmap.php
| | |--autoload_namespaces.php
| | |--autoload_psr4.php
| | |--autoload_real.php
| | |--autoload_static.php
| | |--ClassLoader.php
| | |--installed.json
| | |--LICENSE
| |--autoload.php
|--composer.json

OK,准备工作完成,可以开工了。

MVC搭建

在上一篇文章中如何编写自己的MVC框架已经简单介绍了MVC框架的结构和运作流程。所以不多废话,直接按照MVC的思路逐步搭建出一个小型的MVC框架。

路由

既然用了Composer,就不需要像之前一样自己手动撸代码了,而是直接到全球最大同性交友平台Github搜索关键字router,搜一下
选择一个契(xi)合(huan)项目的包,使用composer进行下载。这里我选择的是klein/klein.php(a fast&flexible router)。没有什么特殊的理由,只是因为出现在了首页,并且stars数量最多而已。
然后打开composer.json,添加安装信息(一般情况下会列在开发者文档中):

1
2
3
4
5
{
"require":{
"klein/klein": "dev-master"
}
}

然后再命令行中运行composer update,等待安装下载完成,速度视网络情况而定。
安装完成后,会在vender文件夹生成相关目录。
在根目录下建立入口文件index.php

1
2
3
4
5
6
<?php
//自动加载
require_once './vendor/autoload.php';

//载入路由文件
require './core/routes.php';

然后新建文件夹core,在里面新建routes.php,使用刚才下载的路由包:

1
2
3
4
5
6
7
8
<?php
$klein = new \Klein\Klein();

$klein->respond('GET', '/hello', function () {
return "hello world!";
});

$klein->dispatch();

打开wamp在本地测试一下能否成功运行。点击Localhost后自动打开浏览器,在地址栏地址后输入/hello会出现hello world!字样,OK运行成功。其实每个包的作者都会在项目中写出详细的开发文档,只要按照给定的API设定好路由即可。在这里以能够实现数据库常用的增删改查为目标构建出这个小型框架。

根据逻辑重构路由文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
$klein = new \Klein\Klein();

//设定默认主页,各项请求在这里完成
$klein->respond(function () {
include './tpl/index.php';
});

//将路由指向控制器文件
$klein->respond('GET', '/create', function () {
Controller::create();
});

$klein->respond('GET', '/update', function () {
Controller::update();
});

$klein->respond('GET', '/retrieve', function () {
Controller::retrieve();
});

$klein->respond('GET', '/delete', function () {
Controller::delete();
});

$klein->dispatch();

模型

在根目录创建文件夹config,新建config.php文件写入数据库配置信息:

1
2
3
4
5
<?php
define("DB_HOST", "localhost");
define("DB_USER", "root");
define("DB_PASSWORD", "1234");
define("DB_NAME", "test");

在core文件夹创建基本的模型类Model.php,在里面使用PDO编写基本的数据库操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php 
//引入数据库配置文件
require './config/config.php';

class Model
{
protected $_dbHandle;
protected $_table;

public function __construct($table)
{
$this ->connect(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
$this ->_table = $table;
}

public function connect($host, $user, $pass, $dbname)
{
try {
$dsn = sprintf("mysql:host=%s;dbname=%s;charset=utf8", $host, $dbname);
$option = array(PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC);
$this->_dbHandle = new PDO($dsn, $user, $pass, $option);
} catch (PDOException $e) {
exit('Wrong: ' . $e->getMessage());
}
}

public function select()
{
$sql = sprintf("SELECT * FROM %s", $this->_table);
$sth = $this->_dbHandle->prepare($sql);
$sth->execute();

return $sth->fetch();
}

public function add($id, $data)
{
$sql = sprintf("INSERT INTO %s(id, name) VALUES (%d, '%s')", $this->_table, $id, $data);
$this ->_dbHandle->query($sql);
}

public function delete($id)
{
$sql = sprintf("DELETE FROM %s WHERE `id` = %d", $this->_table, $id);
$sth = $this->_dbHandle->prepare($sql);
$sth->execute();
}

public function update($id, $data)
{
$sql = sprintf("UPDATE %s SET name = '%s' WHERE `id` = %d", $this->_table, $data, $id);
$this ->_dbHandle->query($sql);
}
}

数据库文件编写完成。

控制器

在core文件夹新建控制器类Controller.php,在其内部使用Model.php与数据库进行连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?php 
class Controller{
public static function create()
{
$model = new Model('test');
$model ->add('1', 'Mike');
echo "插入成功!";
}

public static function update()
{
$model = new Model('test');
$model ->update('1', 'John');
echo "更新成功!";
}

public static function retrieve()
{
$model = new Model('test');
$results = $model ->select();
extract($results);
require "./tpl/index.php";
}

public static function delete()
{
$model = new Model('test');
$model ->delete('1');
echo "删除成功!";
}
}

数据库

1
2
3
4
5
CREATE TABLE `test`(
`id` int(1),
`name` varchar(5)
)ENGINE=Innodb DEFAULT CHARSET=utf8;

自动加载

类文件编写完成后,如果在首页测试会出现找不到相关类的错误。因为还没有在composer.json中设置为自动加载,所以在文件中添加字段,最终代码为:

1
2
3
4
5
6
7
8
9
10
11
{
"require":{
"klein/klein": "dev-master"
},
"autoload":{
"classmap":[
"core"
]
}
}

这个MVC框架的视图层没有特别复杂的实现,而是直接采用了加载文件,输出变量的方式。
至此,这个框架的基本要求已经全部实现了。

小结

在本文中,简单利用了composer的下载和自动加载类的功能,实现了MVC框架。虽然这个框架瘦骨嶙峋到几乎只有骨骼……代码还有非常大的优化和拓展空间。而这些就可以用composer强大的依赖管理来实现。比如项目中需要一个验证码的功能,只需要下载并注册到composer.json中,然后正确引入即可使用。Composer这些功能特性极大方便了PHP开发者的快速开发。

dark
sans