前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >为什么说设计模式和算法是工程师的左右腿?

为什么说设计模式和算法是工程师的左右腿?

作者头像
labuladong
发布2021-09-23 10:11:22
5390
发布2021-09-23 10:11:22
举报
文章被收录于专栏:labuladong的算法专栏

预计阅读时间:10 分钟

我们的公众号之前都是讲算法技巧,并且尽量将算法和实际问题联系起来,今天就聊聊我用设计模式简化解决的一些实际问题,以及一些学习资料的推荐。

还是那句话,我的推荐不会是列一堆书目,而是要让大家明白学这个东西有什么好处,从本文学到些东西。

设计模式和算法被形容为软件工程师的左右腿,很贴切。因为二者都是基本功,看似在工作中用不到,但是无形之中可以增加我们对框架、功能的理解深度。

我第一次感受到设计模式的魅力,还要从我刚开始做公众号说起。

一、我也是个 up 主

技术方面的公众号太多了,我开始根本没有读者,我的一个办法就是去 YouTube 往 B 站搬运算法相关的视频,在评论区推广一下自己嘛。

像处理视频这种重复性工作,当然得写个程序来做啦。因为我搬运的的视频都是英文,肯定得加上字幕吧,YouTube 可以自动生成字幕,原生英文的准确率最高,所以我就准备嵌入英文字幕。

PS:这里顺便科普一下,字幕分为外挂式和内嵌式,我要做的是内嵌式,也就是要一帧一帧把字幕嵌进去,所以说内嵌字幕这个操作属于耗时操作(适合并发处理)。

我的思路是这样的,首先把相应的视频 URL 存到一个名为url的文件中,再把这个文件放到对应的视频文件夹中:

目录结构不就是棵多叉树吗?youtubeVideos目录作为根,然后来一个 DFS 深度优先搜索就可以得到视频对应的 URL、名称和储存路径,有了这些信息,就可以开始下载视频了。

从 YouTube 上得到视频具体来说应该有三步:

1、通过 URL 把视频和字幕文件下载到相应路径。

2、对字幕文件进行优化。

3、把对应的字幕和视频压制到一起。

PS:为什么要对字幕文件进行优化?因为 YouTube 下载的字幕是滚动式的,也就是说视频正在说这句话,但是上句话的字幕还留在屏幕上(类似 KTV 唱歌的歌词),不符合我们看字幕的习惯,所以我观察了字幕文件的规则,写了个简单的算法优化这个问题。

其中第一步和第三步都是耗时操作,我用的 Go 语言并发来提高效率,不过我们这里不展开讲并发,简单来说就是开启多个线程(后文称为worker)同时工作,以最大化利用网络等等资源。

每个worker的工作的流程就是之前说的三步,如下:

代码语言:javascript
复制
// 从任务队列 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 个taskworker们拿了前面 3 个任务,首先执行下载,但是他们执行完下载操作后,都去处理压制字幕这个本地耗时操作了,完全没有去使用网络。理想情况下,应该先处理网络任务,或者说更灵活些,几个worker专门执行网络任务,几个专门执行本地任务。

以上种种是主要问题,我要实现的功能还比较简单,如果功能稍微再复杂些,那真是要命了。直到我学习了看了《Head First 设计模式》,看到了一种模式叫做命令模式……

二、设计一个遥控器

假设你设计一种通用遥控器,长这样:

这个遥控器有一个插槽,旁边有一个on按钮和一个off按钮,比如说你插入电视模块,这两个按钮就能开关电视,插入风扇模块,就能控制开关风扇。

如果要你给这个遥控器编程,你如何设计呢?由于不同模块的 API 不同,最直接的方式就是这样:

代码语言:javascript
复制
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方法即可

代码语言:javascript
复制
/* 命令接口 */
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

代码语言:javascript
复制
type Task interface {
    // 执行命令
    Execute() (error, Task)
    // 报告执行信息(非必须)
    Report()
}

然后下载,优化,合并三个操作都实现这个接口,比如说下载这个操作,我这样实现的:

代码语言:javascript
复制
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}
}

因为三个操作是连续的,所以一个操作执行完之后,返回下一个操作给workerworker会把新的操作(命令对象)添加进任务队列:

代码语言:javascript
复制
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传给workerworker就会重新把这个Task加入任务队列,之后会重新执行。而这些细节都是worker不可见,不需要考虑的。

再稍加改造,main函数的代码也会大大减少,由之前的几十行缩短到十行之内:

代码语言:javascript
复制
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(观察者模式),以前你在代码中遇到这些类名也许只是死记硬背,但其实它们是来源于某些设计模式的。如果你猜到这个功能使用了什么模式,那不就能快速理解使用框架了吗?

哎,从设计层面讲,很多框架思维其实就是这么朴实无华,且枯燥!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-11-08,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 labuladong 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 预计阅读时间:10 分钟
    • 一、我也是个 up 主
      • 二、设计一个遥控器
        • 三、重构代码
          • 四、最后总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档