接口管理工具Apifox在前后端分离项目中的实践

这篇具有很好参考价值的文章主要介绍了接口管理工具Apifox在前后端分离项目中的实践。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

前言

最近在做的集团SaaS平台的派车模块,因实际使用中司机无法操作电脑端,所以又开发了派车小程序以方便司机角色去接单、派车和送货签收操作。小程序端直接调用的是后台的派车模块的接口,这就涉及到了前后端分离中的一个痛点-接口的文档维护和接口的联调测试问题。幸好,在这个全民脱贫、码农翻身把歌唱的时代,我们有了比postman更好用的接口管理工具-Apifox

官方👉[点我直达]给出的介绍:

Apifox 是接口管理、开发、测试全流程集成工具,定位 Postman + Swagger + Mock + JMeter。通过一套系统、一份数据,解决多个系统之间的数据同步问题。只要定义好接口文档,接口调试、数据 Mock、接口测试就可以直接使用,无需再次定义;接口文档和接口开发调试使用同一个工具,接口调试完成后即可保证和接口文档定义完全一致。高效、及时、准确!

Apifox在项目中的实践应用

一、后端接口服务的签名验证规则
  1. 调用 JSON 格式为:

    {
    "accessKey":, //访问key(由系统分配给用户)
    "reqSign":xxxxxxxxxxxxxxxxxxxxxxxxxx, //用一定规则生成的签名
    "timestamp":2022-01-20 13:15:15, //请求时间记录
    "nonce":123456, //小于6位的随机数,用来标识每个被签名的请求
    // "data":{} //查询参数
    }
    
  2. Signature 参数签名生成规则:

    ① 按照请求参数名的字母升序排列非空请求参数(包含AccessKey),使用URL键值对的格式(即 key1=value1&key2=value2…)拼接成字符串stringA

    ② 在stringA最后拼接上用户密钥(32位UUID)得到字符串stringSignTemp
    stringSignTemp进行MD5运算,并将得到的字符串所有字符转换为大写,得到Signature 值。

    返回 JSON 格式为:

    {
    "ok":true, //查询是否成功
    "errorCode":null, //错误码
    "errors":null, //错误信息
    “data”: {} //查询结果数据
    }
    

    具体的调用参数和返回结果中 data 的内容各个功能详细描述。

    验证失败的返回结果是:

    {
    "ok":false,
    "errorCode":-1
    "errors":”用户验证失败”
    "data": null
    }
    

    另外错误返回可能还包括:

    -2:服务过期

    -3: 未购买指定的服务

    -4: 内部错误

二、后端权限过滤器AuthFilter

权限过滤器:

package com.jieguan.filter;

import com.jieguan.entity.ParamDTO;
import com.jieguan.utils.ServiceLicKit;
import com.yorma.constant.RspCode;
import io.zbus.rpc.RpcFilter;
import io.zbus.rpc.annotation.FilterDef;
import io.zbus.transport.Message;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.HashMap;

/**
 * 权限过滤器
 *
 * @author ZHANGCHAO
 * @date 2022/3/31 16:56
 */
@Slf4j
@Component("authFilter1")
@FilterDef("jieguanAuthFilter")
public class AuthFilter implements RpcFilter {

    @Override
    public boolean doFilter(Message request, Message response, Throwable exception) {
        boolean auth = false;
        ParamDTO param = ServiceLicKit.checkParams(request);
        if (!param.isOk()) {
            response.setStatus(RspCode.REQ_ERR);
            response.setBody(param.getErrorMsg());
            return false;
        }
        //校验 NONCE 防重放
        if (!ServiceLicKit.verifyNonce(param.getTimeStamp(), param.getNonce())) {
            response.setStatus(RspCode.UNAUTH);
            response.setHeaders(new HashMap<>());
            response.setBody("校验NONCE未通过,请求拒绝!");
            //校验 URI访问控制
        } else if (!ServiceLicKit.verifyUri(request, param.getLic())) {
            response.setStatus(RspCode.UNAUTH);
            response.setBody("访问受限!");
            //校验 请求签名 防篡改
        } else if (!ServiceLicKit.verifySign(param, request)) {
            response.setStatus(RspCode.UNAUTH);
            response.setBody("非法请求!");
        } else {
            auth = true;
        }
        return auth;
    }
}

权限验证处理类:

package com.jieguan.utils;

import cn.hutool.core.date.DateUnit;
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.jieguan.config.SpringUtil;
import com.jieguan.entity.Nonce;
import com.jieguan.entity.ParamDTO;
import com.jieguan.entity.ServiceLic;
import com.jieguan.entity.ServiceLicUrl;
import com.jieguan.mapper.NonceMapper;
import com.jieguan.mapper.ServiceLicMapper;
import com.jieguan.mapper.ServiceLicUrlMapper;
import com.yorma.util.FileKit;
import com.yorma.util.MD5Util;
import com.yorma.util.StringUtil;
import io.zbus.transport.Message;
import lombok.extern.slf4j.Slf4j;

import java.io.File;
import java.util.*;

import static cn.hutool.core.util.ObjectUtil.isEmpty;
import static cn.hutool.core.util.ObjectUtil.isNotEmpty;
import static cn.hutool.core.util.StrUtil.isBlank;
import static cn.hutool.core.util.StrUtil.isNotBlank;

/**
 * AppKeyKit
 *
 * @author 张杰  2021/11/8 10:39
 * @version 1.0
 * @apiNote <pre>
 *   类简介
 * </pre>
 */
@Slf4j
public class ServiceLicKit {

    public static final String TIMESTAMP = "timestamp";
    public static final String NONCE = "nonce";
    public static final String MD5 = "MD5"; //摘要算法: SM3/MD5
    public static final String SM3 = "SM3"; //摘要算法: SM3/MD5
    public static final int MAX_DELAY = 5; // NONCE间隔时间
    private static final String ACCESS_KEY = "accessKey";
    private static final String SECRET_KEY = "secretKey";
    private static final String SIGN = "reqSign";
    private static final String BODY_HASH = "bodyHash";

    /**
     * 提前验证参数
     *
     * @param request 请求
     * @return com.jieguan.entity.ParamDTO
     * @author ZHANGCHAO
     * @date 2022/1/17 22:56
     */
    public static ParamDTO checkParams(Message request) {
        ParamDTO param = new ParamDTO();
        String accessKey = ServiceLicKit.getKey(ACCESS_KEY, request);
        String sign = ServiceLicKit.getKey(SIGN, request);
        String bodyHash = ServiceLicKit.getKey(BODY_HASH, request);
        ServiceLic lic = ServiceLicKit.getLicByAccessKey(accessKey);
        String timeStamp = ServiceLicKit.getKey(ServiceLicKit.TIMESTAMP, request);
        Long nonce;
        try {
            nonce = Long.valueOf(ServiceLicKit.getKey(ServiceLicKit.NONCE, request));
        } catch (Exception e) {
            param.setErrorMsg("防重放标识nonce不存在或格式错误!");
            return param;
        }
        if (isBlank(accessKey)) {
            param.setErrorMsg("未获取到用户标识AccessKey!");
            return param;
        }
        if (isBlank(sign)) {
            param.setErrorMsg("未获取到参数签名sign!");
            return param;
        }
        if (isBlank(timeStamp)) {
            param.setErrorMsg("未获取到请求时间戳timestamp!");
            return param;
        }
        if (isEmpty(nonce)) {
            param.setErrorMsg("未获取到防重放标识nonce!");
            return param;
        }
        if (isEmpty(lic)) {
            param.setErrorMsg("未获取到此用户标识的许可信息!");
            return param;
        }
        param.setOk(true)
                .setAccessKey(accessKey)
                .setSign(sign)
                .setBodyHash(bodyHash)
                .setTimeStamp(timeStamp)
                .setNonce(nonce)
                .setLic(lic);
        log.info("[参数检查]最终的Param:" + param);
        return param;
    }

    /**
     * 查询许可, 根据accessKey
     *
     * @param accessKey
     * @return
     */
    public static ServiceLic getLicByAccessKey(String accessKey) {
        ServiceLicMapper licMapper = (ServiceLicMapper) SpringUtil.getBean("serviceLicMapper");
        ServiceLic serviceLic = licMapper.selectOne(new QueryWrapper<ServiceLic>().lambda()
                .eq(ServiceLic::getAccessKey, accessKey)
                .eq(ServiceLic::getIsWhite, true));
        if (isEmpty(serviceLic)) {
            return null;
        }
        ServiceLicUrlMapper licUrlMapper = (ServiceLicUrlMapper) SpringUtil.getBean("serviceLicUrlMapper");
        List<ServiceLicUrl> serviceLicUrls = licUrlMapper.selectList(new QueryWrapper<ServiceLicUrl>().lambda()
                .eq(ServiceLicUrl::getLicId, serviceLic.getId()));
        Set<String> urlSet = new HashSet<>();
        if (isNotEmpty(serviceLicUrls)) {
            for (ServiceLicUrl url : serviceLicUrls) {
                urlSet.add(url.getLicUrl());
            }
        }
        serviceLic.setUrlSet(urlSet);
        return serviceLic;
    }

    /**
     * 取参数或头的属性值(参数优先)
     *
     * @param key
     * @param msg
     * @return
     */
    public static String getKey(String key, Message msg) {
        String val = null;
        if (StringUtil.isNotEmpty(key) && msg != null) {
            val = msg.getParam(key) == null ? msg.getHeader(key) : msg.getParam(key, String.class);
        }
        return val;
    }

    /**
     * 校验 NONCE
     *
     * @param timeStamp
     * @param nonce
     * @return
     */
    public static boolean verifyNonce(String timeStamp, long nonce) {
        long betweenTime = DateUtil.between(DateUtil.parseDateTime(timeStamp), new Date(), DateUnit.MINUTE, false);
        // 超出5分钟时间范围?
        if (betweenTime > MAX_DELAY || betweenTime < 0) {
            log.info("[校验NONCE]超出时间范围,请求拒绝!");
            return false;
        }
        NonceMapper nonceMapper = (NonceMapper) SpringUtil.getBean("nonceMapper");
        Nonce nonceRecord = nonceMapper.selectOne(new QueryWrapper<Nonce>().lambda().eq(Nonce::getNonce, nonce));
        if (isNotEmpty(nonceRecord)) {
            log.info("[校验NONCE]已存在的NONCE,请求拒绝!");
            nonceRecord.setAttackTimes(isNotEmpty(nonceRecord.getAttackTimes()) ? nonceRecord.getAttackTimes() + 1 : 1);
            nonceMapper.updateById(nonceRecord);
            return false;
        }
        Nonce nonceNew = new Nonce();
        nonceNew.setNonce(nonce).setReqTime(DateUtil.parseDateTime(timeStamp));
        nonceMapper.insert(nonceNew);
        return true;
    }

    /**
     * 访问权限验证
     *
     * @param msg
     * @param lic
     * @return
     */
    public static boolean verifyUri(Message msg, ServiceLic lic) {
        String uri = msg.getUrl();
        String queryStr = msg.getQueryString();
        uri = uri.replace("?" + queryStr, "");
        return isNotEmpty(lic.getUrlSet()) && lic.getUrlSet().contains(uri);
    }

    /**
     * 验证请求签名
     * 原文= paramStr[&timeValue][&nonceValue][&bodyHashHEXValue]&secretKeyValue]
     * paramStr: 请求参数原文(不含‘?’,保持顺序)
     * bodyHashHEXValue:POST/PUT需要计算 bodyHash值,算法 MD5/SM3, 格式 HEX
     * secretKeyValue: 根据 accessKey 获取服务端记录的 secretKeyValue
     *
     * @param param
     * @param req
     * @return
     */
    public static boolean verifySign(ParamDTO param, Message req) {
        boolean rt = false;
        String src = isBlank(req.getQueryString()) ? "" : req.getQueryString().replaceFirst("&?" + SIGN + "=[0-9,a-f,A-F]+", "");
        String sign = param.getSign();

        // 时间戳
        if (!src.contains(TIMESTAMP + "=")) {
            src += "&" + param.getTimeStamp();
        }
        // nonce
        if (!src.contains(NONCE + "=")) {
            src += "&" + param.getNonce();
        }
        // bodyHash
        if (param.getBodyHash() != null && !src.contains(BODY_HASH + "=")) {
            src += "&" + param.getBodyHash();
        }
        src += "&" + param.getLic().getSecretKey();

        // MD5 16byte, SM3 32byte
        String alg = sign.length() == 64 ? SM3 : MD5; //param.getLic().getAlgorithm();//
        String localSign = signature(src, alg);
        String localBodyHash = "";
        if (isNotBlank(param.getBodyHash())) {
            String sourtJson = getSortJson(req.getBody());
            log.info("sortJson:" + sourtJson);
            localBodyHash = signature(sourtJson, alg);
        }
        log.info("[src]:" + src);
        if (!localSign.equalsIgnoreCase(sign)) {
            log.info("[src]:" + src);
            log.info("[sign]:" + sign);
            log.info("[localSign]:" + localSign);
        } else if (isNotBlank(param.getBodyHash()) && !localBodyHash.equalsIgnoreCase(param.getBodyHash())) {
            log.info("[bodyHash]:" + param.getBodyHash());
            log.info("[localBodyHash]:" + localBodyHash);
        } else {
            rt = true;
        }

        return rt;
    }

    /**
     * 对请求签名
     * <p>
     * MD5{参数串|body串|key}
     *
     * @param src
     * @param alg
     * @return
     */
    public static String signature(String src, String alg) {
        // FIXME 原文结构待定
        String sign = null;
        if (StringUtil.isNotEmpty(alg)) {
            switch (alg.toUpperCase()) {
                case MD5:
                    sign = MD5Util.MD5Encode(src, "UTF-8");
                    break;
                case SM3:
                    sign = SM3Digest.hashHex(src, "UTF-8");
                    break;
                default://不支持的算法
                    log.info("不支持算法:" + alg);
            }
        }
        return sign;
    }

    /**
     * 对单层json进行key字母排序
     *
     * @param json
     * @return
     */
    public static String getSortJson(Object json) {
        if (json instanceof JSONArray || json instanceof JSONObject) {
            return JSONObject.toJSONString(getSortMap(json));
        } else if (json instanceof String) {
            JSONObject jsonObject;
            try {
                jsonObject = JSONObject.parseObject((String) json);
            } catch (Exception e) {
                throw new RuntimeException("不是 JSON 对象: " + json);
            }
            return JSONObject.toJSONString(getSortMap(jsonObject));
        } else {
            throw new RuntimeException("不是 JSON 对象: " + json);
        }
    }

    public static Object getSortMap(Object json) {
        SortedMap map = new TreeMap();
        if (json instanceof JSONArray && !((JSONArray) json).isEmpty()
                && ((JSONArray) json).get(0) instanceof JSONObject) {
            JSONArray va = (JSONArray) json;
            for (int i = 0; i < va.size(); i++) {
                va.set(i, getSortMap(va.get(i)));
            }
            return json;

        } else if (json instanceof JSONObject) {
            Iterator<String> iteratorKeys = ((JSONObject) json).keySet().iterator();
            while (iteratorKeys.hasNext()) {
                String key = iteratorKeys.next();
                Object value = ((JSONObject) json).get(key);
                if (value instanceof JSONObject || value instanceof JSONArray) {
                    map.put(key, getSortMap(value));

                } else if (value != null) {
                    map.put(key, value);
                }
            }
            return map;
        } else {
            return json;
        }
    }

}

三、Apifox编写公共脚本用于前置操作,设置接口请求签名sign

公共脚本主要用途是实现脚本复用,避免多处重复编写相同功能的脚本

可以将多处都会用到的相同功能的脚本或者通用的类、方法,放到公共脚本里,然后所有接口直接引用公共脚本即可使用。

在项目设置里新建一个公共脚本,请求前根据一定规则生成公共的请求头,编写生成签名的代码,可参考官方使用文档,讲的都很详细👉接口签名如何处理:

接口管理工具Apifox在前后端分离项目中的实践

接口管理工具Apifox在前后端分离项目中的实践

脚本代码:

// 设置请求头timestamp
var moment = require("moment");
var timestamp = moment().format('YYYY-MM-DD HH:mm:ss')
console.log(timestamp)

/**
 * 6位随机数
 */
function getNonce() {
  let nonce = Math.random().toString().slice(-6);
  if (nonce.startsWith("0")) {
    nonce = getNonce();
  }
  return nonce;
}
var nonce = getNonce();
console.log(nonce)

// // 获取 Header 参数对象
// var headers = pm.request.headers;
// // 获取 key 为 field1 的 header 参数的值
// var accessKey = pm.variables.replaceIn(headers.get("accessKey"));
// console.log(accessKey)

// 存放所有需要用来签名的参数
let param = {};

// 加入 query 参数
let queryParams = pm.request.url.query;

queryParams.each(item => {
  // if (item.value !== '') { // 非空参数值的参数才参与签名
  param[item.key] = item.value;
  // }
});

// 取 key
let keys = [];
for (let key in param) {
    // 注意这里,要剔除掉 sign 参数本身
    if (key !== 'sign') {
        keys.push(key);
    }
}

// 转成键值对
let paramPair = [];
for (let i = 0, len = keys.length; i < len; i++) {
    let k = keys[i];
    paramPair.push(k + '=' + encodeURIComponent(param[k])) // urlencode 编码
}

paramPair.push(timestamp);
paramPair.push(nonce);
paramPair.push("cjf9hbd4rln75a58o3tc");

// 最后加上 key
// paramPair.push("key=" + key);

// 拼接
let stringSignTemp = paramPair.join('&');

if (queryParams == null || queryParams == '') {
  stringSignTemp = "&" + stringSignTemp;
}
console.log(stringSignTemp);

let sign = CryptoJS.MD5(stringSignTemp).toString();
console.log(sign);

// 方案一:直接修改接口请求的 query 参数,注入 sign,无需使用环境变量。
// 参考文档:https://www.apifox.cn/help/app/scripts/examples/request-handle/
// queryParams.upsert({
//     key: 'sign',
//     value: sign,
// });

// 方案二:写入环境变量,此方案需要在接口里设置参数引用环境变量
// 设置全局变量
pm.globals.set("reqSign", sign);
pm.globals.set("timestamp", timestamp);
pm.globals.set("nonce", nonce);

四、调用接口,验证权限

接口管理工具Apifox在前后端分离项目中的实践

通过Apifox调用接口,成功返回数据,可以在控制台查看调用时发送的参数等信息:

接口管理工具Apifox在前后端分离项目中的实践

接口管理工具Apifox在前后端分离项目中的实践

另外分享一个MD5加密的脚本:

接口管理工具Apifox在前后端分离项目中的实践

let password = pm.request.url.query.get('password');
console.log('原密码:' + password);
let newPwd = CryptoJS.MD5(password).toString();
console.log('MD5加密后:' + newPwd);
pm.request.url.query.upsert({
    key: "password",
    value: newPwd,
});
总结

作为一款国人开发的工具,Apifox已经很优秀了,起码比postman“智能化”不少,但是很烦网络上铺天盖地的广告软文,只有凭自己的实力赢得口碑才是硬道理!

以上文章来源地址https://www.toymoban.com/news/detail-469854.html

到了这里,关于接口管理工具Apifox在前后端分离项目中的实践的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包赞助服务器费用

相关文章

  • Maven —— 项目管理工具

    Maven —— 项目管理工具

            在这篇文章中,荔枝会介绍如何在项目工程中借助Maven的力量来开发,主要涉及Maven的下载安装、环境变量的配置、IDEA中的Maven的路径配置和信息修改以及通过Maven来快速构建项目。希望能对需要配置的小伙伴们有帮助哈哈哈哈~~~ 前言 一、初识Maven 1.1 Maven作用:

    2024年02月16日
    浏览(10)
  • SpringBoot + Vue前后端分离项目实战 || 四:用户管理功能实现

    SpringBoot + Vue前后端分离项目实战 || 四:用户管理功能实现

    系列文章: SpringBoot + Vue前后端分离项目实战 || 一:Vue前端设计 SpringBoot + Vue前后端分离项目实战 || 二:Spring Boot后端与数据库连接 SpringBoot + Vue前后端分离项目实战 || 三:Spring Boot后端与Vue前端连接 SpringBoot + Vue前后端分离项目实战 || 四:用户管理功能实现 SpringBoot + Vue前后

    2024年02月11日
    浏览(24)
  • SpringBoot + Vue前后端分离项目实战 || 五:用户管理功能后续

    SpringBoot + Vue前后端分离项目实战 || 五:用户管理功能后续

    系列文章: SpringBoot + Vue前后端分离项目实战 || 一:Vue前端设计 SpringBoot + Vue前后端分离项目实战 || 二:Spring Boot后端与数据库连接 SpringBoot + Vue前后端分离项目实战 || 三:Spring Boot后端与Vue前端连接 SpringBoot + Vue前后端分离项目实战 || 四:用户管理功能实现 SpringBoot + Vue前后

    2024年02月16日
    浏览(32)
  • 操作员管理 微人事 项目 SpringBooot + Vue 前后端分离

    操作员管理 微人事 项目 SpringBooot + Vue 前后端分离

    HrController HrService HrMapper mysql逻辑: 查询Hr 和 角色之间的信息 每个Hr有哪些角色,除了当前用户不查 SysHr.vue 展示效果 问题Bug解决 还有一个BUG 因:org.apache.ibatis.reflection.ReflectionException:非法重载getter方法,在类class com.chb.vhr.bean.Hr中启用属性类型不明确。这违反了JavaBeans规范,并

    2024年02月11日
    浏览(12)
  • 开源项目管理工具Plane

    开源项目管理工具Plane

    本文软件由网友 不长到一百四誓不改名 推荐,不过这次是在他推荐之前,就已经完成了的 🙂 什么是 Plane ? Plane 是一个简单的、可扩展的、开源的项目和产品管理工具。它允许用户从一个基本的任务跟踪工具开始,逐步采用各种项目管理框架,如 Agile 、 Waterfall 等。 在群

    2024年02月12日
    浏览(10)
  • git(项目版本管理工具)快速入门

    git(项目版本管理工具)快速入门

    目录 1、git 1.1、git概述 1.2、git的服务器地址 1.3、git原理 2、客户端操作 2.1、初始化本地库 2.2、添加本地暂存区  2.3、提交本地库 2.4、修改文件 2.5、查看修改历史 2.6、查看版本差异 2.7、删除文件 2.8、文件还原 3、git命令 3.1、初始化本地库 3.2、查看本地仓库状态 3.3、添加本

    2023年04月12日
    浏览(9)
  • 推荐三款Scrum敏捷项目管理工具/敏捷管理实践

    免费版敏捷工具推荐: Leangoo领歌 Leangoo领歌是ScrumCN(scrum.cn)旗下的一款 永久免费的专业敏捷开发管理工具 ,提供端到端敏捷研发管理解决方案,涵盖敏捷需求管理、任务协同、进展跟踪、缺陷管理、统计度量等。包括小型团队敏捷开发,规模化敏捷SAFe,Scrum of Scrums大规模

    2024年02月11日
    浏览(20)
  • Flutter项目的sdk版本管理工具

    Flutter项目的sdk版本管理工具

    flutter項目的sdk版本使用是一個很尴尬的问题,一个项目一个SDK,电脑系统还只能装一个SDK,这就使我们开发当中很尴尬,好几个项目分别使用不同的SDK就很难办了,不可能来回升级降级SDK,现在市面有SDK版本管理工具,虽然不是很友好,但是也算一个解决办法,下面说一下解

    2024年04月14日
    浏览(12)
  • 【开源项目】go-admin前后端分离权限管理系统

    【开源项目】go-admin前后端分离权限管理系统

    基于Gin + Vue + Element UI OR Arco Design OR Ant Design的前后端分离权限管理系统,系统初始化极度简单,只需要配置文件中,修改数据库连接,系统支持多指令操作,迁移指令可以让初始化数据库信息变得更简单,服务指令可以很简单的启动api服务 在线文档 前端项目 视频教程 Element

    2024年02月11日
    浏览(13)
  • 软件开发项目管理工具哪个好?

    软件开发项目管理工具哪个好?

    瀑布模型是一种按照固定的阶段顺序进行项目开发的方法,它要求在进入下一个阶段之前,必须完成当前阶段的所有任务。瀑布模型的优点是清晰、简单、易于控制,但也存在一些缺点,如缺乏灵活性、难以应对需求变化、风险较高等。 为了克服瀑布模型的局限性,许多项目

    2023年04月09日
    浏览(15)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包