我们的公众号之前都是讲算法技巧,并且尽量将算法和实际问题联系起来,今天就聊聊我用设计模式简化解决的一些实际问题,以及一些学习资料的推荐。
还是那句话,我的推荐不会是列一堆书目,而是要让大家明白学这个东西有什么好处,从本文学到些东西。
设计模式和算法被形容为软件工程师的左右腿,很贴切。因为二者都是基本功,看似在工作中用不到,但是无形之中可以增加我们对框架、功能的理解深度。
我第一次感受到设计模式的魅力,还要从我刚开始做公众号说起。
技术方面的公众号太多了,我开始根本没有读者,我的一个办法就是去 YouTube 往 B 站搬运算法相关的视频,在评论区推广一下自己嘛。
像处理视频这种重复性工作,当然得写个程序来做啦。因为我搬运的的视频都是英文,肯定得加上字幕吧,YouTube 可以自动生成字幕,原生英文的准确率最高,所以我就准备嵌入英文字幕。
PS:这里顺便科普一下,字幕分为外挂式和内嵌式,我要做的是内嵌式,也就是要一帧一帧把字幕嵌进去,所以说内嵌字幕这个操作属于耗时操作(适合并发处理)。
我的思路是这样的,首先把相应的视频 URL 存到一个名为url
的文件中,再把这个文件放到对应的视频文件夹中:
目录结构不就是棵多叉树吗?youtubeVideos
目录作为根,然后来一个 DFS 深度优先搜索就可以得到视频对应的 URL、名称和储存路径,有了这些信息,就可以开始下载视频了。
从 YouTube 上得到视频具体来说应该有三步:
1、通过 URL 把视频和字幕文件下载到相应路径。
2、对字幕文件进行优化。
3、把对应的字幕和视频压制到一起。
PS:为什么要对字幕文件进行优化?因为 YouTube 下载的字幕是滚动式的,也就是说视频正在说这句话,但是上句话的字幕还留在屏幕上(类似 KTV 唱歌的歌词),不符合我们看字幕的习惯,所以我观察了字幕文件的规则,写了个简单的算法优化这个问题。
其中第一步和第三步都是耗时操作,我用的 Go 语言并发来提高效率,不过我们这里不展开讲并发,简单来说就是开启多个线程(后文称为worker
)同时工作,以最大化利用网络等等资源。
每个worker
的工作的流程就是之前说的三步,如下:
// 从任务队列 tasks 中接收任务
for task := range tasks {
// 获得 URl
url := task[0]
// 获得路径
path := task[1]
dirs := strings.Split(path, "/")
// 获得视频名称
name := dirs[len(dirs)-2]
// 开始走视频制作的流程
DownloadVideos(url, path)
TransformSubtitle(path)
MergeSubtitle(path)
// 制作完一个视频
done <- true
}
整体逻辑很简单直接,main
函数先用 DFS 算法找到所有url
文件及其路径(两个字符串),把它们放到任务队列tasks
里,然后放出若干个worker
,每个worker
会从任务队列取任务执行,main
等到所有任务执行完成后结束,没有意外的话所有视频应该都下载好,并且添加字幕了。
PS:如何添加任务和去除任务是需要一点并发技巧的,不过这里就忽略这些细节了。
这样简单粗暴地实现功能,是可以运转的,毕竟我每次不会下载太多视频,勉强可以满足需求。出现的具体问题有:
1、work
做的事太复杂,要接收任务数据,从中分解出任务参数,然后下载优化合并一条龙。worker
知道的细节太多不是好事,代码行数多,如果要做一点修改会很难受(亲测)。
2、下载,优化,合并这三个操作如果出错怎么处理?按道理如果某个操作出现错误,应该重试若干次,但是这个逻辑之下很难添加重试逻辑,如果强行添加,会使代码更加臃肿复杂。
3、上述三个操作是一个序列,关联性很强,如果某一个操作出现意外(比如说该视频并没有字幕文件),会导致后面的操作都失败,这些错误都会写入 log,造成干扰。按道理应该在出错一次之后停止之后的操作。
4、对于整个流程,第一步下载需要网络,后续两步操作都是在本地进行的,所以第一步应该优先处理,因为网络资源比较值钱嘛。但是我这个逻辑下网络资源利用是周期性的,设想一下,如果有 3 个worker
和 10 个task
,worker
们拿了前面 3 个任务,首先执行下载,但是他们执行完下载操作后,都去处理压制字幕这个本地耗时操作了,完全没有去使用网络。理想情况下,应该先处理网络任务,或者说更灵活些,几个worker
专门执行网络任务,几个专门执行本地任务。
以上种种是主要问题,我要实现的功能还比较简单,如果功能稍微再复杂些,那真是要命了。直到我学习了看了《Head First 设计模式》,看到了一种模式叫做命令模式……
假设你设计一种通用遥控器,长这样:
这个遥控器有一个插槽,旁边有一个on
按钮和一个off
按钮,比如说你插入电视模块,这两个按钮就能开关电视,插入风扇模块,就能控制开关风扇。
如果要你给这个遥控器编程,你如何设计呢?由于不同模块的 API 不同,最直接的方式就是这样:
class RemoteControl {
// 卡槽插的控制模块
private Solt obj;
// on 被按下执行的操作
public pushOn() {
if (obj is Light) {
obj.on();
} else if (obj is Door) {
obj.open();
} else if (...)
}
// off 被按下执行的操作
public pushOff() {
if (obj is Light) {
obj.off();
} else if (obj is Door) {
obj.close();
} else if (...)
}
}
这样设计非常糟糕,所有逻辑都堆在开关按钮,它们连控制模块的细节都必须知道,代码量巨大。
试想如果厂家生产了一种新的控制模块,你这个遥控器的开关没有相应的逻辑就无法使用,那么之前的所有遥控器都得更新,这是多蠢的一件事呀!
命令模式可以解决这个问题,核心思想是将每一个命令包装成一个命令对象,每个命令对象实现一个Command
接口,包含一个execute
方法,这个方法定义了每个操作的具体流程;这些细节对于遥控器上的按钮全部隐藏,按钮只管调用execute
方法即可。
/* 命令接口 */
interface Command {
void execute();
}
/* 关灯的命令 */
class LightoffCmd implements Command {
// 命令的接收者
private Light light;
public ListOffCmd(Light light) {
this.light = light;
}
public void execute() {
light.off();
}
}
/* 开音乐的命令 */
class MusicOnCmd implements Command {
// 命令的接收者
private CD cd;
public MusicOnCmd (CD cd) {
this.cd = cd;
}
// 命令的调用者只管 execute,细节已被包装
public void execute() {
cd.on();
cd.playMusic();
cd.setVolume(10);
}
}
/* 遥控器对象 */
class RemoteControl {
// 命令对象
private Command onCmd, offCmd;
// 由模块传入相应的操作
public void setCommand(Command on, Command off) {
this.onCmd = on;
this.offCmd = off;
}
// on 被按下执行的操作
public pushOn() {
onCmd.execute();
}
// off 被按下执行的操作
public pushOff() {
offCmd.execute();
}
}
这样设计功能,遥控器就获得了很高的可用性,甭管开、关动作具体需要调用什么 API,遥控器按钮只管调用 execute。
就算出了新的控制模块,只要模块包装好相应的命令对象,传递给遥控器的setCommand
方法,遥控器不需要知道具体的细节也可以正确控制相应的设备了。
这就是命令模式的魅力,可以利用命令对象将命令的调用者(遥控器的按钮)和接收者(卡槽内的控制模块)之间解耦,这样调用者就不需要知道具体细节,只管调用接口方法execute
就行了。
学完命令模式,我就突然想到之前写的制作视频的程序,每个线程就是个莫得感情的worker
,就像遥控器的按钮,不希望知道太多细节逻辑,那么是否可以参考命令模式来重写一下代码呢?
首先写一个接口(Go 语言的语法,不过意思都一样),这里的Task
就是前文的Command
:
type Task interface {
// 执行命令
Execute() (error, Task)
// 报告执行信息(非必须)
Report()
}
然后下载,优化,合并三个操作都实现这个接口,比如说下载这个操作,我这样实现的:
type DownloadTask struct{
path string
url string
}
// 相当于类的构造函数
func NewDownloadTask(path string, url string) *DownloadTask {
return &DownloadTask{path: path, url: url}
}
// 相当于类的方法。执行具体操作,并返回下一个命令。
func (t DownloadTask) Execute() (error, Task) {
err = do_download(t.path, t.url);
if err != nil {
return err, nil
}
// 如果没有出错,返回优化字幕的命令
return nil, TransformTask{path:t.path}
}
因为三个操作是连续的,所以一个操作执行完之后,返回下一个操作给worker
,worker
会把新的操作(命令对象)添加进任务队列:
func createWorker(get chan Task, post chan Task) {
for {// while true
// 从任务队列获取任务
task := <- get
// 执行任务并得到下一个任务
err, nextTask := task.Execute()
// 报告当前任务信息
task.Report()
if err != nil {
continue
}
// 将新任务添加进任务队列
post <- nextTask
}
}
你看,现在worker
只需要取任务、报告信息、执行、添加新任务就行了。
execute
方法中可以实现重试之类的功能,比如说再把自己作为nextTask
传给worker
,worker
就会重新把这个Task
加入任务队列,之后会重新执行。而这些细节都是worker
不可见,不需要考虑的。
再稍加改造,main
函数的代码也会大大减少,由之前的几十行缩短到十行之内:
func main() {
// 获得所有路径和 URL
urls, paths := utils.DFS(config.Pwd)
// 设置最大线程数
e := engine.NewSimpleEngine(3)
// 将路径和 URL 传入命令对象
var seeds []interfaces.Task;
for i := 0; i < len(urls); i++ {
seeds = append(seeds, tasks.NewStartTask(paths[i], urls[i]))
}
// 开始制作视频
e.Run(seeds...)
}
具体代码不展开了,无非就是把原来的逻辑模块化,各司其职,有条不紊地处理任务,可以用下面一张图来表示一下大致流程:
当然,我这个制作视频的例子不算严格的命令模式,因为涉及到并发,涉及到工作队列等等模型,但是核心思想还是解耦,以简单的结构应对复杂的细节问题。
不必纠结具体模式的定义,设计模式在一开始就告诉我们:唯一不变的就是变化。不要为了使用模式而使用模式,模式可以变化,终极目标就是用设计合理的结构,去应对变化莫测的问题。
个人感觉,算法就像是一把锋利的尖刀,可以极其高效地解决复杂问题;设计模式就像一张蓝图,掌握得不好,你就只能玩玩乐高积木,掌握得好,你也许可以尝试去盖高楼大厦。
我们也许不会接触什么高大上的项目,但是编程框架总是要用的吧,如果你懂设计模式类图中的一些常用词语,比如Invoker
(命令模式),Context
(状态模式),Adapter
(适配器模式),Stub
(代理模式),Listener
(观察者模式),以前你在代码中遇到这些类名也许只是死记硬背,但其实它们是来源于某些设计模式的。如果你猜到这个功能使用了什么模式,那不就能快速理解使用框架了吗?
哎,从设计层面讲,很多框架思维其实就是这么朴实无华,且枯燥!