How it works(10) NodeODM源码阅读(A) 鉴权与任务初始化

引入

OpenDroneMap(ODM)是一款非常强大的无人机成果处理软件,可以直接将无人机拍摄的照片处理成正摄影像甚至进行三维建模.ODM本身是基于python的OpenSFM编写的命令行工具,为了方便实际使用,NodeODM出现了.
NodeODM是Nodejs编写的一套带有可视化界面的API,实现了通过接口上传图片,修改配置,获取进度等常用功能.因此我们一般用的都是NodeODM,很少会直接调用ODM.
NodeODM对外是将命令行封装为接口,内部主要实现了以下的功能:

  • 任务排队与分配
  • 简单的鉴权
  • 任务执行后处理

代码架构

惯例是madge生成的结构图

文件架构

因为NodeODM是一个前后端一体项目,文件组织比较复杂,也需要说一下:

从架构图可知,整个NodeODM围绕一大一下两个功能展开:

  • 鉴权相关
  • 任务相关

鉴权

NodeODM没有用户体系,因此采用的是静态token鉴权.因为整个NodeODM是基于express框架搭建的接口,所以所谓的鉴权,就是在请求中最先处理的中间件:

const auth = require('./libs/auth/factory').fromConfig(config);
const authCheck = auth.getMiddleware();
app.get('/task/:uuid/info', authCheck, getTaskFromUuid, (req, res) => {
    res.json(req.task.getInfo());
});

鉴权中间件采用工厂模式:

const NoTokenRequiredAuth = require('./NoTokenRequiredAuth');
const SimpleTokenAuth = require('./SimpleTokenAuth');

module.exports = {
    fromConfig: function(config){
        if (config.token){
            return new SimpleTokenAuth(config.token);
        }else{
            return new NoTokenRequiredAuth();
        }
    }
}

鉴权模型有两种:

  • 无鉴权
  • 简单鉴权

这两者都基于一个抽象的鉴权基类:

module.exports = class TokenAuthBase{
    initialize(cb){
        cb();
    }
    cleanup(cb){
        cb();
    }
    //虚函数,需要重写
    validateToken(token, cb){ cb(new Error("Not implemented"), false); }
    //生成中间件
    getMiddleware(){
        return (req, res, next) => {
            this.validateToken(req.query.token, (err, valid) => {
                if (valid) next();
                else{
                    res.json({ error: "Invalid authentication token: " + err.message });
                }
            });
        };
    }
};

两种鉴权模式继承了该基类

//简单鉴权
module.exports = class SimpleTokenAuth extends TokenAuthBase{
    constructor(token){
        super(token);
        this.token = token;
    }

    validateToken(token, cb){
        if (this.token === token){
            return cb(null, true);
        }else{
            cb(new Error("token does not match."), false);
        }
    }
};

//无鉴权
module.exports = class NoTokenRequiredAuth extends TokenAuthBase{
    // 永远返回true
    validateToken(token, cb){ cb(null, true); }
};

虽然这是一种聊胜于无的鉴权方式,但它定义了一个很好的模式:如果有第三种复杂/动态的鉴权方式,采用工厂模式可以保证对上层的影响最小.继承基类的方式能保证对整个鉴权模块的影响最小.

任务

相比可有可无的鉴权,任务是整个NodeODM最重要的部分.任务相关由三大部分组成:

任务的运行流程也与这三部分都有关系:

任务运行前

由上面的流程图可知,任务的创建分4个小步骤:

  • 构建环境
  • 初始化配置
  • 存储图片
  • 任务开始运行
构建环境

初始化环境发生在NodeODM启动之时,只会初始化一次,主要目的是准备相应的文件夹,清理无用的旧任务文件夹等:

constructor(done){
    this.tasks = {};
    //正在运行的任务
    this.runningQueue = [];
    //顺序执行下列清理
    async.series([
        cb => this.restoreTaskListFromDump(cb),
        cb => this.removeOldTasks(cb),
        cb => this.removeOrphanedDirectories(cb),
        cb => this.removeStaleUploads(cb),// 移除没有对应任务的文件夹
        cb => {
            this.processNextTask();//自动开始状态为排队的任务
            cb();
        },
        cb => {
            // 建立定时任务,每小时都进行清除,
            schedule.scheduleJob('0 * * * *', () => {
                this.removeOldTasks();
                this.dumpTaskList();//将配置文件存储在硬盘上
                this.removeStaleUploads();
            });
            cb();
        }
    ], done);
}

//加载全部已存在的任务
restoreTaskListFromDump(done){
    fs.readFile(TASKS_DUMP_FILE, (err, data) => {
        if (!err){
            let tasks;
            try{
                tasks = JSON.parse(data.toString());
            }catch(e){
                done(new Error(`It looks like the ${TASKS_DUMP_FILE} is corrupted (${e.message}).`));
                return;
            }
      //重新序列化每一个任务
            async.each(tasks, (taskJson, done) => {
                Task.CreateFromSerialized(taskJson, (err, task) => {
                    if (err) done(err);
                    else{
                      //将任务添加回任务列表
                        this.tasks[task.uuid] = task;
                        done();
                    }
                });
            }, err => {
                logger.info(`Initialized ${tasks.length} tasks`);
                if (done !== undefined) done();
            });
        }else{
            logger.info("No tasks dump found");
            if (done !== undefined) done();
        }
    });
}

//移除过早的任务
removeOldTasks(done){
    let list = [];
    let now = new Date().getTime();
    logger.debug("Checking for old tasks to be removed...");

    for (let uuid in this.tasks){
        let task = this.tasks[uuid];
    //任务创建时间
        let dateFinished = task.dateCreated;
        if (task.processingTime > 0) dateFinished += task.processingTime;
    //收集所有过早的已失败已完成的已或取消的任务(允许存在正在运行或正在排队的过早任务)
        if ([statusCodes.FAILED,
            statusCodes.COMPLETED,
            statusCodes.CANCELED].indexOf(task.status.code) !== -1 &&
            now - dateFinished > CLEANUP_TASKS_IF_OLDER_THAN){
            list.push(task.uuid);
        }
    }
  //为什么不在上步直接remove?因为remove是异步的,上面的是同步方法,调用的话无法保证已完成
    async.eachSeries(list, (uuid, cb) => {
        logger.info(`Cleaning up old task ${uuid}`);
        this.remove(uuid, cb);
    }, done);
}
初始化配置

从接口定义可以看出,初始化配置总共经历了简单鉴权,配置UUID到初始化任务:

app.post('/task/new/init', authCheck, taskNew.assignUUID, taskNew.handleInit);

UUID是追踪每一个任务的唯一标识,生成了UUID,就可以安心的构建该任务的不重名工作区文件夹:

assignUUID: (req, res, next) => {
    //用户可以自行指定uuid
    if (req.get('set-uuid')){
        const userUuid = req.get('set-uuid');
        // 验证用户给定的UUID是否合法并且没有重名
        if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(userUuid) && !TaskManager.singleton().find(userUuid)){
            req.id = userUuid;
            next();
        }else{
            res.json({error: `Invalid set-uuid: ${userUuid}`})
        }
    }else{
        //生成一个UUID
        req.id = uuidv4();
        next();
    }
}
handleInit: (req, res) => {
        req.body = req.body || {};
        //在临时文件夹下生成工作区
        const srcPath = path.join("tmp", req.id);
        const bodyFile = path.join(srcPath, "body.json");
        // 出错的时候删掉工作区
        const die = (error) => {
            res.json({error});
            removeDirectory(srcPath);
        };
    //顺序执行下列任务
        async.series([
            cb => {
                // 检测指定的配置信息是否合法
                if (req.body && req.body.options){
                    odmInfo.filterOptions(req.body.options, err => {
                        if (err) cb(err);
                        else cb();
                    });
                }else cb();
            },
            cb => {
              //检测要生成的文件夹是否不存在
                fs.stat(srcPath, (err, stat) => {
                    if (err && err.code === 'ENOENT') cb();
                    else cb(new Error(`Directory exists (should not have happened: ${err.code})`));
                });
            },
            //创建该文件夹
            cb => fs.mkdir(srcPath, undefined, cb),
            //将配置序列化,也存入该文件
            cb => {
                fs.writeFile(bodyFile, JSON.stringify(req.body), {encoding: 'utf8'}, cb);
            },
            //返回生成的UUID供客户端后面使用
            cb => {
                res.json({uuid: req.id});
                cb();
            }
        ], err => {
            if (err) die(err.message);
        });
   },
存储图片

从接口定义可以看出,存储图片总共经历了简单鉴权,验证UUID,图片上传,返回结果几步:

app.post('/task/new/upload/:uuid', authCheck, taskNew.getUUID, taskNew.uploadImages, taskNew.handleUpload);

上传图片前,首先验证的是UUID是否合法,确定要向哪个任务上传图片:

getUUID: (req, res, next) => {
    req.id = req.params.uuid;
    if (!req.id) res.json({error: `Invalid uuid (not set)`});
    //验证是否存在uuid对应的文件夹下有配置文件存在
    const srcPath = path.join("tmp", req.id);
    const bodyFile = path.join(srcPath, "body.json");
    fs.access(bodyFile, fs.F_OK, err => {
        if (err) res.json({error: `Invalid uuid (not found)`});
        else next();
    });
},

上传图片的处理采用了express官方的multer模块:

//接收表单中的"images"字段上传的图片
uploadImages: upload.array("images"),
const upload = multer({
    storage: multer.diskStorage({
        destination: (req, file, cb) => {
            let dstPath = path.join("tmp", req.id);
            fs.exists(dstPath, exists => {
                if (!exists) {
                    fs.mkdir(dstPath, undefined, () => {
                        cb(null, dstPath);
                    });
                } else {
                    cb(null, dstPath);
                }
            });
        },
        filename: (req, file, cb) => {
            let filename = utils.sanitize(file.originalname);
            //因为body.json是内置的配置存储文件,避免在此被替换掉
            if (filename === "body.json") filename = "_body.json";
            cb(null, filename);
        }
    })
});
任务开始运行

进入任务最重要的环节:执行.从接口定义可以看出,任务执行总共经历了简单鉴权,验证UUID,执行前处理,任务执行几步:

app.post('/task/new/commit/:uuid', authCheck, taskNew.getUUID, taskNew.handleCommit, taskNew.createTask);

在执行任务前,需要为执行准备参数,即待处理的任务文件和任务参数.从代码可以看出,NodeODM的参数都会固化在本地文件内,用的时候序列化,修改后再存入本地,而不是一直维持在内存里:

handleCommit: (req, res, next) => {
        const srcPath = path.join("tmp", req.id);
        const bodyFile = path.join(srcPath, "body.json");
        //顺序执行下列动作
        async.series([
            //先读取配置文件(文件夹下有全部图片和配置文件)
            cb => {
                fs.readFile(bodyFile, 'utf8', (err, data) => {
                    if (err) cb(err);
                    else {
                        try {
                            //序列化配置文件后删除它
                            const body = JSON.parse(data);
                            fs.unlink(bodyFile, err => {
                                if (err) cb(err);
                                else cb(null, body);
                            });
                        } catch (e) {
                            cb("Malformed body.json");
                        }
                    }
                });
            },
            //读取所有图片(现在只剩图片了)
            cb => fs.readdir(srcPath, cb),
        ], (err, [body, files]) => {
            if (err) res.json({
                error: err.message
            });
            else {
                //将配置和文件列表挂载到request对象上,供后面的中间件使用
                req.body = body;
                req.files = files;
                if (req.files.length === 0) {
                    req.error = "Need at least 1 file.";
                }
                next();
            }
        });
    },

一切准备妥当,就可以开启任务了:

createTask: (req, res) => {
    req.setTimeout(1000 * 60 * 20);
    const srcPath = path.join("tmp", req.id);
    // 遇到致命错误时,就返回错误信息,并尝试删除文件
    const die = (error) => {
        res.json({error});
        removeDirectory(srcPath);
    };
    if (req.error !== undefined){
        die(req.error);
    }else{
      //正式存储成果的文件夹
        let destPath = path.join(Directories.data, req.id);
        let destImagesPath = path.join(destPath, "images");
        let destGcpPath = path.join(destPath, "gcp");
        async.series([
          //验证配置参数是否合法
            cb => {
                odmInfo.filterOptions(req.body.options, (err, options) => {
                    if (err) cb(err);
                    else {
                        req.body.options = options;
                        cb(null);
                    }
                });
            },
            //保证最终成果文件夹不存在
            cb => {
                if (req.files && req.files.length > 0) {
                    fs.stat(destPath, (err, stat) => {
                        if (err && err.code === 'ENOENT') cb();
                        else cb(new Error(`Directory exists (should not have happened: ${err.code})`));
                    });
                } else {
                    cb();
                }
            },
            //如果提供的是url,则直接下载该zip文件
            cb => {
                if (req.body.zipurl) {
                    let archive = "zipurl.zip";
                    upload.storage.getDestination(req, archive, (err, dstPath) => {
                        if (err) cb(err);
                        else{
                         //下载
             const download = function(uri, filename, callback) {
                request.head(uri, function(err, res, body) {
                    if (err) callback(err);
                    else request(uri).pipe(fs.createWriteStream(filename)).on('close', callback);
                });
              };
             //getDestination函数返回的是当前任务的临时文件夹
             //zip包最终会被下载到该文件夹
                          let archiveDestPath = path.join(dstPath, archive);
                          download(req.body.zipurl, archiveDestPath, cb);
                        }
                    });
                } else {
                    cb();
                }
            },
            //将所有文件从临时目录移到最终目录去
            cb => fs.mkdir(destPath, undefined, cb),
            cb => fs.mkdir(destGcpPath, undefined, cb),
            cb => mv(srcPath, destImagesPath, cb),
            cb => {
                // 解压存在的zip文件
                fs.readdir(destImagesPath, (err, entries) => {
                    if (err) cb(err);
                    else {
                        async.eachSeries(entries, (entry, cb) => {
                            if (/\.zip$/gi.test(entry)) {
                                let filesCount = 0;
                                fs.createReadStream(path.join(destImagesPath, entry)).pipe(unzip.Parse())
                                        .on('entry', function(entry) {
                                            if (entry.type === 'File') {
                                                filesCount++;
                                                entry.pipe(fs.createWriteStream(path.join(destImagesPath, path.basename(entry.path))));
                                            } else {
                                                entry.autodrain();
                                            }
                                        })
                                        .on('close', () => {
                                            // 解压完成后检测是不是会文件过多
                                            if (config.maxImages && filesCount > config.maxImages) cb(`${filesCount} images uploaded, but this node can only process up to ${config.maxImages}.`);
                                            else cb();
                                        })
                                        .on('error', cb);
                            } else cb();
                        }, cb);
                    }
                });
            },
            cb => {
                // 寻找控制点描述文件,移动到正确的位置,并删除所有zip文件
                // also remove any lingering zipurl.zip
                fs.readdir(destImagesPath, (err, entries) => {
                    if (err) cb(err);
                    else {
                        async.eachSeries(entries, (entry, cb) => {
                            if (/\.txt$/gi.test(entry)) {
                                mv(path.join(destImagesPath, entry), path.join(destGcpPath, entry), cb);
                            }else if (/\.zip$/gi.test(entry)){
                                fs.unlink(path.join(destImagesPath, entry), cb);
                            } else cb();
                        }, cb);
                    }
                });
            },
            // 创建任务并运行
            cb => {
                new Task(req.id, req.body.name, (err, task) => {
                    if (err) cb(err);
                    else {
                        TaskManager.singleton().addNew(task);
                        res.json({ uuid: req.id });
                        cb();
                    }
                }, req.body.options,
                req.body.webhook,
                req.body.skipPostProcessing === 'true');
            }
        ], err => {
            if (err) die(err.message);
        });
    }
}

至此,建立一个任务并运行需要调用4个接口.
当然NodeODM也提供了合而为一的接口,简化调用:

app.post('/task/new', authCheck, taskNew.assignUUID, taskNew.uploadImages, (req, res, next) => {
    req.body = req.body || {};
    if ((!req.files || req.files.length === 0) && !req.body.zipurl) req.error = "Need at least 1 file or a zip file url.";
    else if (config.maxImages && req.files && req.files.length > config.maxImages) req.error = `${req.files.length} images uploaded, but this node can only process up to ${config.maxImages}.`;
    next();
}, taskNew.createTask);
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值