2024年7月

开心一刻

出门扔垃圾,看到一大爷摔地上了

过去问大爷:我账户余额 0.8,能扶你起来不

大爷往旁边挪了挪

跟我说到:孩子,快,你也躺下,这个来钱快!

我没理大爷,径直去扔了垃圾

然后飞速的躺在了大爷旁边,说道:感觉大爷带飞!

撞死碰瓷鬼

书接上回

通过
异构数据源同步之数据同步 → DataX 使用细节
,相信大家都知道如何使用
DataX

但你们有没有发现一个问题:
job.json

reader

writer
的账密都是明文

账密明文

这就犹如在裸奔,是有安全隐患的!

不仅你们喜欢看裸奔,其实我也喜欢看裸奔

但不管是从法律的角度,还是从道德的角度,裸奔都是不允许的!

所以我们应该怎么办,给她穿上衣服?

少管闲事

那就穿嘛,而且给她穿厚点,让她安安全全的!

加密

首先我们得明确,目前
DataX
是支持加解密的,
dataxPluginDev.md
有这样一段说明

DataX
框架支持对特定的配置项进行RSA加密,例子中以
*
开头的项目便是加密后的值。
配置项加密解密过程对插件是透明,插件仍然以不带
*
的key来查询配置和操作配置项

从这段话我们可以解析出以下几点信息

  1. 采用
    RSA
    算法进行加密,且暂时只支持这一种!

  2. 敏感信息配置项的
    key

    *
    开头,例如

    {
      "job": {
        "content": [
          {
            "reader": {
              "name": "oraclereader",
              "parameter": {
                "*username": "加密后的密文",
                "*password": "加密后的密文",
                ...
              }
            },
            "writer": {
              "name": "oraclewriter",
              "parameter": {
                "*username": "加密后的密文",
                "*password": "加密后的密文",
                ...
              }
            }
          }
        ]
      }
    }
    
  3. plugin
    不参与加密解密,言外之意就是
    FrameWork
    负责解密,至于加密嘛,你们先想想


除了以上 3 点,你们还能分析出什么?

  1. 如何获得明文的密文
  2. 配置了密文,需不需要通过额外的配置告知 DataX 需要解密

这两点能分析出来吗?

关于第 1 点,我把
DataX
的文档翻遍了,没找到给明文加密的说明,莫非就用通用的
RSA
工具加密就行?

关于第 2 点,这个暂时不得而知,但是我们可以去试

获取密文

DataX
只说支持
RSA
加密,但没说如何获取密文,但我们仔细想一下,其实是能找到切入点的。
DataX
肯定有解密过程,而解密与加密往往是成对存在的,找到了解密方法也就找到了加密方法,那上哪去找解密方法了?
源码
肯定是最根本的方式!

源码之下无密码

前面已经说过了,
FrameWork
负责解密,对应的模块就是
datax-core
,从它的
Engine.java
切入

为什么从 Engine.java 切入,可以看看
异构数据源同步之数据同步 → datax 改造,有点意思

另外,Engine.java 的描述也说明了

Engine是DataX入口类,该类负责初始化Job或者Task的运行容器,并运行插件的Job或者Task逻辑


main
一步一步往下跟

Engine#main > Engine#entry > ConfigParser#parse > ConfigParser#parseJobConfig > SecretUtil#decryptSecretKey

decrypt
大家都知道是什么意思吧,所以
SecretUtil.java
中肯定有我们要找的加密方法

多个加密方法

但我们会发现有好几个,我们应该用哪个?凭感觉的话应该是
encryptRSA
,但作为一个开发者,我们不能只凭感觉,我们需要的准确的答案。如何寻找准备的答案了?

从解密处找答案,解密用的哪个方法,可以准确的推出加密方法

那就继续跟进
SecretUtil#decryptSecretKey

decryptSecretKey

代码不短,但我们暂时只需要关注图中标明的 2 点

  1. 是否需要解密

    还记得前面提到的问题吗


    配置了密文,需不需要通过额外的配置告知 DataX 需要解密


    所以
    DataX
    是通过配置项
    job.setting.keyVersion
    来判断是否需要解密,得到明确的答案,我们就不用去尝试了

  2. 对包含
    *
    号的
    key
    解密

    我们跟进
    SecretUtil.decrypt

    public static String decrypt(String data, String key, String method) {
    	if (SecretUtil.KEY_ALGORITHM_RSA.equals(method)) {
    		return SecretUtil.decryptRSA(data, key);
    	} else if (SecretUtil.KEY_ALGORITHM_3DES.equals(method)) {
    		return SecretUtil.decrypt3DES(data, key);
    	} else {
    		throw DataXException.asDataXException(
    				FrameworkErrorCode.SECRET_ERROR,
    				String.format("系统编程错误,不支持的加密类型", method));
    	}
    }
    

    代码并不长,但我们发现除了支持
    RSA
    解密,还支持
    3DES
    解密,这与官方文档说的


    DataX
    框架支持对特定的配置项进行RSA加密


    有点不一样,为什么不把
    3DES
    加进去?这个后面再分析,我们继续看
    RSA

所以对应的
RSA
解密方法是:
SecretUtil.decryptRSA
,那对应的加密方法肯定就是
SecretUtil.encryptRSA
,但为了严谨,我们需要验证下,如何验证了,其实很简单,
SecretUtil.encryptRSA
对明文加密得到密文,然后用
SecretUtil.decryptRSA
对密文进行解密,看能否得到最初的明文

但问题又来了,
encryptRSA
需要
公钥

/**
 * 加密<br>
 * 用公钥加密 encryptByPublicKey
 *
 * @param data 裸的原始数据
 * @param key  经过base64加密的公钥
 * @return 结果也采用base64加密
 * @throws Exception
 */
public static String encryptRSA(String data, String key) {
	try {
		// 对公钥解密,公钥被base64加密过
		byte[] keyBytes = decryptBASE64(key);

		// 取得公钥
		X509EncodedKeySpec x509KeySpec = new X509EncodedKeySpec(keyBytes);
		KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM_RSA);
		Key publicKey = keyFactory.generatePublic(x509KeySpec);

		// 对数据加密
		Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
		cipher.init(Cipher.ENCRYPT_MODE, publicKey);

		return encryptBASE64(cipher.doFinal(data.getBytes(ENCODING)));
	} catch (Exception e) {
		throw DataXException.asDataXException(
				FrameworkErrorCode.SECRET_ERROR, "rsa加密出错", e);
	}
}


decryptRSA
需要
私钥

/**
 * 解密<br>
 * 用私钥解密
 *
 * @param data 已经经过base64加密的密文
 * @param key  已经经过base64加密私钥
 * @return
 * @throws Exception
 */
public static String decryptRSA(String data, String key) {
	try {
		// 对密钥解密
		byte[] keyBytes = decryptBASE64(key);

		// 取得私钥
		PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(keyBytes);
		KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM_RSA);
		Key privateKey = keyFactory.generatePrivate(pkcs8KeySpec);

		// 对数据解密
		Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
		cipher.init(Cipher.DECRYPT_MODE, privateKey);

		return new String(cipher.doFinal(decryptBASE64(data)), ENCODING);
	} catch (Exception e) {
		throw DataXException.asDataXException(
				FrameworkErrorCode.SECRET_ERROR, "rsa解密出错", e);
	}
}

上哪去获取
公钥

私钥

愁人

假设是我们实现工具类
SecretUtil
,我们要不要提供获取
公钥

私钥
的方法?很显然是要的,因为
加密

解密
分别需要用到
公钥

私钥
,所以从完整性考虑,肯定提供获取
公钥

私钥
的方法

自己动手,丰衣足食

同理,
SecretUtil
也提供了获取
公钥
和 私钥的方法

/**
 * 初始化密钥 for RSA ALGORITHM
 *
 * @return
 * @throws Exception
 */
public static String[] initKey() throws Exception {
	KeyPairGenerator keyPairGen = KeyPairGenerator
			.getInstance(KEY_ALGORITHM_RSA);
	keyPairGen.initialize(1024);

	KeyPair keyPair = keyPairGen.generateKeyPair();

	// 公钥
	RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

	// 私钥
	RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();

	String[] publicAndPrivateKey = {
			encryptBASE64(publicKey.getEncoded()),
			encryptBASE64(privateKey.getEncoded())};

	return publicAndPrivateKey;
}

测试代码如下

public static void main(String[] args) throws Exception {
	// 获取公钥与私钥
	String[] keys = SecretUtil.initKey();
	String publicKey = keys[0];
	String privateKey = keys[1];
	System.out.println("publicKey = " + publicKey);
	System.out.println("privateKey = " + privateKey);

	// 通过公钥加密
	String encryptData = SecretUtil.encryptRSA("hello_qsl", publicKey);
	System.out.println("encryptData = " + encryptData);

	// 通过私钥解密
	String decryptData = SecretUtil.decryptRSA(encryptData, privateKey);
	System.out.println("decryptData = " + decryptData);
}

至于结果正确与否,你们自己去执行

我都把饭喂到你们嘴里了,莫非还要我替你们去吃?

你们不要太过分

使用密文

密文已经获取到了,接下来就是在
DataX
中使用密文了

  1. 配置
    .secret.properties

    公钥

    私钥

    文件在
    DataX

    home
    目录的
    conf
    目录下

    #ds basicAuth config
    auth.user=
    auth.pass=
    current.keyVersion=v1
    current.publicKey=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCeBjq0zrij7A6la1y9gONHrC3dV1r8U4HCJ0expJ6K9xeW1/RYUc+s4b4pEQjbeSX2BlmOzCXPcc2s26+UpHLHl9Cy1alix/HGf3tOubuAKsbF+MKOd/sLGtLoFr4iMoCHj+KNVRBHlQN5WsrxehRwQaqWycl2Rd2wY6orL0xZ0QIDAQAB
    current.privateKey=MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAJ4GOrTOuKPsDqVrXL2A40esLd1XWvxTgcInR7Gknor3F5bX9FhRz6zhvikRCNt5JfYGWY7MJc9xzazbr5SkcseX0LLVqWLH8cZ/e065u4AqxsX4wo53+wsa0ugWviIygIeP4o1VEEeVA3layvF6FHBBqpbJyXZF3bBjqisvTFnRAgMBAAECgYEAhtcl7PagUy+wZ7KvFf0O8y+Wi1JpDvpqtLMz1/9yUX36oPpxQ5O7s/eEfiJM/onnvIE6lkDY2qRvLlre/eU9En4f964p6Fl0yWMalDmCv8MYGNEBu8rzn+GKH55xzm+Z5shs7mvzFWYJNeHIHCI8fmHnscFURB8VYEgAvbvtHAECQQDePgz/j4rTyqYeFjzuwZe7wUlQzex2NNdJ/aP4RY2+v+N5lZbT0SomIJZhIf5uqY+Z3lmEEyLWEikiDD6GkAihAkEAtgcLQJ6D4XOujJwD8KWm9m78yKXTrEgk57Qpy0bQq9tF2ygd6m2u8oEo9x+3YpN2J9RaTykjyOP8YwoSW97TMQJBAIgWkRkRCd7E8dHspiVBsKtNIZr0bf64PrjVM0n9NV3/3Mh//Fr6cwfj3pHeIhIbjI6ZJFGG8kcJ2dw6iTMXEeECQD6N6SYJ05SU5rVXoFsA8oHZ3nEt27JnEJe36Gz9JxUIQ9duz+kSTH72OBfFBIaR2pcReP+fSbbt8nwup+R+jOECQQCciE2p6iQcTnJyMuSQFLoTB47qSx0EmdQNIcLdHuAxagWrfphlPJMFPJilWWgaqyoP0GkzmPxak5Jd9T7bv7yR
    current.service.username=
    current.service.password=
    

    current.keyVersion
    也需要配置,并且后续
    job.json
    的配置
    job.setting.keyVersion
    的值与该值一致

  2. 配置
    job.json

    有 2 种配置,其 1 是需要配置:
    job.setting.keyVersion
    ,其值与
    .secret.properties

    current.keyVersion
    值一致,其 2 是
    reader

    writer
    的账密
    key
    需要以
    *
    开头,并且其值需要置成加密后的密文,完整的
    mysql2Mysql.json

    {
        "job": {
            "setting": {
                "speed": {
                    "channel": 5
                },
                "errorLimit": {
                    "record": 0,
                    "percentage": 0.02
                },
                "keyVersion": "v1"
            },
            "content": [
                {
                    "reader": {
                        "name": "mysqlreader",
                        "parameter": {
                            "*username": "HisZeJWc51c+8B54AbJ9wQDTJ49C1kBlc1hKUnDgi1NaTdqsgHwRc3Y4PdM5xf0fCLRoYlLSO/KRZJcy9CGIQt9uvJy3bkbG01RwO4qMoS+nQJ28S8p/I3rVUlAEkI/eE/PFWBnAU2U4xF2XjlMFrCG2yetAlZuwsN4paQaBmj4=",
                            "*password": "Ebh0U200enVevXaJs6M0t4yvPo5upcL8RUBN2j1Xi59a8UF8iSPbCl/m5YcX4N9JcJH6VPdsA9kfDJHv6tArnCsH3f5JDWwapOv03lW6B3Nte89e+7Ex7tE6J5+IkFIxaxeYOGoTFr+NBf5t4DWzK0tvH2xAVTgiPHyL/gisiZI=",
                            "column": [
                                "id",
                                "username",
                                "password",
                                "birth_day",
                                "remark"
                            ],
                            "connection": [
                                {
                                    "jdbcUrl": [
                                        "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8"
                                    ],
                                    "table": [
                                        "qsl_datax_source"
                                    ]
                                }
                            ]
                        }
                    },
                    "writer": {
                        "name": "mysqlwriter",
                        "parameter": {
                            "writeMode": "insert",
                            "*username": "HisZeJWc51c+8B54AbJ9wQDTJ49C1kBlc1hKUnDgi1NaTdqsgHwRc3Y4PdM5xf0fCLRoYlLSO/KRZJcy9CGIQt9uvJy3bkbG01RwO4qMoS+nQJ28S8p/I3rVUlAEkI/eE/PFWBnAU2U4xF2XjlMFrCG2yetAlZuwsN4paQaBmj4=",
                            "*password": "Ebh0U200enVevXaJs6M0t4yvPo5upcL8RUBN2j1Xi59a8UF8iSPbCl/m5YcX4N9JcJH6VPdsA9kfDJHv6tArnCsH3f5JDWwapOv03lW6B3Nte89e+7Ex7tE6J5+IkFIxaxeYOGoTFr+NBf5t4DWzK0tvH2xAVTgiPHyL/gisiZI=",
                            "column": [
                                "id",
                                "username",
                                "pw",
                                "birth_day",
                                "note"
                            ],
                            "connection": [
                                {
                                    "jdbcUrl": "jdbc:mysql://192.168.2.118:3306/qsl_datax_sync?useUnicode=true&characterEncoding=utf-8",
                                    "table": [
                                        "qsl_datax_target"
                                    ]
                                }
                            ]
                        }
                    }
                }
            ]
        }
    }
    

然后执行数据同步

datax.py ../job/mysql2Mysql.json

输出日志如下

DataX (DATAX-OPENSOURCE-3.0), From Alibaba !
Copyright (C) 2010-2017, Alibaba Group. All Rights Reserved.


2024-07-13 23:49:17.313 [main] INFO  MessageSource - JVM TimeZone: GMT+08:00, Locale: zh_CN
2024-07-13 23:49:17.315 [main] INFO  MessageSource - use Locale: zh_CN timeZone: sun.util.calendar.ZoneInfo[id="GMT+08:00",offset=28800000,dstSavings=0,useDaylight=false,transitions=0,lastRule=null]
2024-07-13 23:49:17.321 [main] INFO  VMInfo - VMInfo# operatingSystem class => sun.management.OperatingSystemImpl
2024-07-13 23:49:17.323 [main] INFO  Engine - the machine info  =>

        osInfo: Windows 10 amd64 10.0
        jvmInfo:        Oracle Corporation 1.8 25.251-b08
        cpu num:        8

        totalPhysicalMemory:    -0.00G
        freePhysicalMemory:     -0.00G
        maxFileDescriptorCount: -1
        currentOpenFileDescriptorCount: -1

        GC Names        [PS MarkSweep, PS Scavenge]

        MEMORY_NAME                    | allocation_size                | init_size
        PS Eden Space                  | 256.00MB                       | 256.00MB
        Code Cache                     | 240.00MB                       | 2.44MB
        Compressed Class Space         | 1,024.00MB                     | 0.00MB
        PS Survivor Space              | 42.50MB                        | 42.50MB
        PS Old Gen                     | 683.00MB                       | 683.00MB
        Metaspace                      | -0.00MB                        | 0.00MB


2024-07-13 23:49:17.331 [main] INFO  Engine -
{
        "setting":{
                "speed":{
                        "channel":5
                },
                "errorLimit":{
                        "record":0,
                        "percentage":0.02
                },
                "keyVersion":"v1"
        },
        "content":[
                {
                        "reader":{
                                "name":"mysqlreader",
                                "parameter":{
                                        "column":[
                                                "id",
                                                "username",
                                                "password",
                                                "birth_day",
                                                "remark"
                                        ],
                                        "connection":[
                                                {
                                                        "jdbcUrl":[
                                                                "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8"
                                                        ],
                                                        "table":[
                                                                "qsl_datax_source"
                                                        ]
                                                }
                                        ],
                                        "username":"root",
                                        "password":"******"
                                }
                        },
                        "writer":{
                                "name":"mysqlwriter",
                                "parameter":{
                                        "writeMode":"insert",
                                        "column":[
                                                "id",
                                                "username",
                                                "pw",
                                                "birth_day",
                                                "note"
                                        ],
                                        "connection":[
                                                {
                                                        "jdbcUrl":"jdbc:mysql://192.168.2.118:3306/qsl_datax_sync?useUnicode=true&characterEncoding=utf-8",
                                                        "table":[
                                                                "qsl_datax_target"
                                                        ]
                                                }
                                        ],
                                        "username":"root",
                                        "password":"******"
                                }
                        }
                }
        ]
}

2024-07-13 23:49:17.342 [main] INFO  PerfTrace - PerfTrace traceId=job_-1, isEnable=false
2024-07-13 23:49:17.342 [main] INFO  JobContainer - DataX jobContainer starts job.
2024-07-13 23:49:17.343 [main] INFO  JobContainer - Set jobId = 0
Sat Jul 13 23:49:17 GMT+08:00 2024 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
2024-07-13 23:49:17.614 [job-0] INFO  OriginalConfPretreatmentUtil - Available jdbcUrl:jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8&yearIsDateType=false&zeroDateTimeBehavior=convertToNull&tinyInt1isBit=false&rewriteBatchedStatements=true.
Sat Jul 13 23:49:17 GMT+08:00 2024 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
2024-07-13 23:49:17.635 [job-0] INFO  OriginalConfPretreatmentUtil - table:[qsl_datax_source] has columns:[id,username,password,birth_day,remark].
2024-07-13 23:49:17.796 [job-0] INFO  OriginalConfPretreatmentUtil - table:[qsl_datax_target] all columns:[
id,username,pw,birth_day,note
].
2024-07-13 23:49:17.801 [job-0] INFO  OriginalConfPretreatmentUtil - Write data [
insert INTO %s (id,username,pw,birth_day,note) VALUES(?,?,?,?,?)
], which jdbcUrl like:[jdbc:mysql://192.168.2.118:3306/qsl_datax_sync?useUnicode=true&characterEncoding=utf-8&yearIsDateType=false&zeroDateTimeBehavior=convertToNull&rewriteBatchedStatements=true&tinyInt1isBit=false]
2024-07-13 23:49:17.801 [job-0] INFO  JobContainer - jobContainer starts to do prepare ...
2024-07-13 23:49:17.802 [job-0] INFO  JobContainer - DataX Reader.Job [mysqlreader] do prepare work .
2024-07-13 23:49:17.802 [job-0] INFO  JobContainer - DataX Writer.Job [mysqlwriter] do prepare work .
2024-07-13 23:49:17.803 [job-0] INFO  JobContainer - jobContainer starts to do split ...
2024-07-13 23:49:17.803 [job-0] INFO  JobContainer - Job set Channel-Number to 5 channels.
2024-07-13 23:49:17.806 [job-0] INFO  JobContainer - DataX Reader.Job [mysqlreader] splits to [1] tasks.
2024-07-13 23:49:17.807 [job-0] INFO  JobContainer - DataX Writer.Job [mysqlwriter] splits to [1] tasks.
2024-07-13 23:49:17.825 [job-0] INFO  JobContainer - jobContainer starts to do schedule ...
2024-07-13 23:49:17.826 [job-0] INFO  JobContainer - Scheduler starts [1] taskGroups.
2024-07-13 23:49:17.828 [job-0] INFO  JobContainer - Running by standalone Mode.
2024-07-13 23:49:17.834 [taskGroup-0] INFO  TaskGroupContainer - taskGroupId=[0] start [1] channels for [1] tasks.
2024-07-13 23:49:17.836 [taskGroup-0] INFO  Channel - Channel set byte_speed_limit to -1, No bps activated.
2024-07-13 23:49:17.836 [taskGroup-0] INFO  Channel - Channel set record_speed_limit to -1, No tps activated.
2024-07-13 23:49:17.844 [taskGroup-0] INFO  TaskGroupContainer - taskGroup[0] taskId[0] attemptCount[1] is started
2024-07-13 23:49:17.848 [0-0-0-reader] INFO  CommonRdbmsReader$Task - Begin to read record by Sql: [select id,username,password,birth_day,remark from qsl_datax_source
] jdbcUrl:[jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8&yearIsDateType=false&zeroDateTimeBehavior=convertToNull&tinyInt1isBit=false&rewriteBatchedStatements=true].
Sat Jul 13 23:49:17 GMT+08:00 2024 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
2024-07-13 23:49:17.869 [0-0-0-reader] INFO  CommonRdbmsReader$Task - Finished read record by Sql: [select id,username,password,birth_day,remark from qsl_datax_source
] jdbcUrl:[jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8&yearIsDateType=false&zeroDateTimeBehavior=convertToNull&tinyInt1isBit=false&rewriteBatchedStatements=true].
2024-07-13 23:49:18.247 [taskGroup-0] INFO  TaskGroupContainer - taskGroup[0] taskId[0] is successed, used[403]ms
2024-07-13 23:49:18.247 [taskGroup-0] INFO  TaskGroupContainer - taskGroup[0] completed it's tasks.
2024-07-13 23:49:27.842 [job-0] INFO  StandAloneJobContainerCommunicator - Total 4 records, 80 bytes | Speed 8B/s, 0 records/s | Error 0 records, 0 bytes |  All Task WaitWriterTime 0.000s |  All Task WaitReaderTime 0.012s | Percentage 100.00%
2024-07-13 23:49:27.842 [job-0] INFO  AbstractScheduler - Scheduler accomplished all tasks.
2024-07-13 23:49:27.843 [job-0] INFO  JobContainer - DataX Writer.Job [mysqlwriter] do post work.
2024-07-13 23:49:27.843 [job-0] INFO  JobContainer - DataX Reader.Job [mysqlreader] do post work.
2024-07-13 23:49:27.843 [job-0] INFO  JobContainer - DataX jobId [0] completed successfully.
2024-07-13 23:49:27.844 [job-0] INFO  HookInvoker - No hook invoked, because base dir not exists or is a file: F:\datax\hook
2024-07-13 23:49:27.845 [job-0] INFO  JobContainer -
         [total cpu info] =>
                averageCpu                     | maxDeltaCpu                    | minDeltaCpu
                -1.00%                         | -1.00%                         | -1.00%


         [total gc info] =>
                 NAME                 | totalGCCount       | maxDeltaGCCount    | minDeltaGCCount    | totalGCTime        | maxDeltaGCTime     | minDeltaGCTime
                 PS MarkSweep         | 1                  | 1                  | 1                  | 0.014s             | 0.014s             | 0.014s
                 PS Scavenge          | 1                  | 1                  | 1                  | 0.006s             | 0.006s             | 0.006s

2024-07-13 23:49:27.845 [job-0] INFO  JobContainer - PerfTrace not enable!
2024-07-13 23:49:27.846 [job-0] INFO  StandAloneJobContainerCommunicator - Total 4 records, 80 bytes | Speed 8B/s, 0 records/s | Error 0 records, 0 bytes |  All Task WaitWriterTime 0.000s |  All Task WaitReaderTime 0.012s | Percentage 100.00%
2024-07-13 23:49:27.846 [job-0] INFO  JobContainer -
任务启动时刻                    : 2024-07-13 23:49:17
任务结束时刻                    : 2024-07-13 23:49:27
任务总计耗时                    :                 10s
任务平均流量                    :                8B/s
记录写入速度                    :              0rec/s
读出记录总数                    :                   4
读写失败总数                    :                   0

数据同步成功,我们注意看日志中的
job.json

{
    "setting": {
        "speed": {
            "channel": 5
        },
        "errorLimit": {
            "record": 0,
            "percentage": 0.02
        },
        "keyVersion": "v1"
    },
    "content": [
        {
            "reader": {
                "name": "mysqlreader",
                "parameter": {
                    "column": [
                        "id",
                        "username",
                        "password",
                        "birth_day",
                        "remark"
                    ],
                    "connection": [
                        {
                            "jdbcUrl": [
                                "jdbc:mysql://192.168.2.118:3307/qsl_datax?useUnicode=true&characterEncoding=utf-8"
                            ],
                            "table": [
                                "qsl_datax_source"
                            ]
                        }
                    ],
                    "username": "root",
                    "password": "******"
                }
            },
            "writer": {
                "name": "mysqlwriter",
                "parameter": {
                    "writeMode": "insert",
                    "column": [
                        "id",
                        "username",
                        "pw",
                        "birth_day",
                        "note"
                    ],
                    "connection": [
                        {
                            "jdbcUrl": "jdbc:mysql://192.168.2.118:3306/qsl_datax_sync?useUnicode=true&characterEncoding=utf-8",
                            "table": [
                                "qsl_datax_target"
                            ]
                        }
                    ],
                    "username": "root",
                    "password": "******"
                }
            }
        }
    ]
}

可以看出
FrameWork
完成解密后,带
*
号的
key

username

password
)已经不带
*
号,其值也已经被解密成了明文

"password": "**********",只是进行日志打印的时候,一个明文字符被替换成一个
*
,而实际传给插件的是明文密码

所以插件并不感知加密解密过程,这就是官方文档说的

配置项加密解密过程对插件是透明,插件仍然以不带
*
的key来查询配置和操作配置项

解密

官方文档只提到了
RSA
,但实际代码中还提供了
3DES
,为什么官方文档中不提及
3DES
了,我们得从解密中找答案

还记得前面讲到的
SecretUtil#decryptSecretKey
吗,我们得继续看这个方法,但只需要分析其中部分代码

获取密钥配置

主要分两块

  1. getPrivateKeyMap
    获取
    密钥

    方法代码比较长,就不展示代码了,我直接给你们梳理下流程

    本地缓存
    versionKeyMap
    类型是
    Map<String, Triple<String, String, String>>

    key
    是就是
    .secret.properties

    current.keyVersion
    的值,对应到我们案例中,就是
    v1

    value

    Triple
    类型,含有三个字段,
    left

    middle

    right
    ,值分别对应
    privateKey

    加密算法

    publicKey

    如果
    versionKeyMap

    null
    ,则读取
    .secret.properties
    内容,放入
    versionKeyMap
    中;如果不是
    null
    ,则直接返回。重点代码来了,大家注意看


    getPrivateKeyMap

    红框框住的代码,相信大家都能看懂,
    keyVersion
    的值就是配置项
    current.keyVersion
    的值,
    privateKey
    的值就是配置项
    current.privateKey
    的值,
    publicKey
    的值就是配置项
    current.publicKey
    的值

    大家注意看

    versionKeyMap.put(keyVersion, ImmutableTriple.of(
    	privateKey, SecretUtil.KEY_ALGORITHM_RSA,
    	publicKey))
    

    这里直接将加密算法固定成
    RSA
    了,根本就没有
    if
    分支去指定
    3DES
    算法,所以了?


    DataX 暂时确实不支持
    3DES
    加解密,只支持
    RSA
    加解密


    或者说
    3DES
    加解密只实现了部分,未实现全部,最终还是不支持
    3DES
    ,所以官方文档只说了
    RSA
    ,并未提及
    3DES
    是对的!

  2. 获取
    私钥

    加密算法

    decryptKey
    就是
    privateKey
    ,而
    method
    就是
    加密算法
    ,其值就是
    RSA
    。然后就是对
    job.json

    *
    开头的
    key
    的值做解密处理

    // 对包含*号key解密处理
    for (String key : config.getKeys()) {
    	int lastPathIndex = key.lastIndexOf(".") + 1;
    	String lastPathKey = key.substring(lastPathIndex);
    	if (lastPathKey.length() > 1 && lastPathKey.charAt(0) == '*'
    			&& lastPathKey.charAt(1) != '*') {
    		Object value = config.get(key);
    		if (value instanceof String) {
    			String newKey = key.substring(0, lastPathIndex)
    					+ lastPathKey.substring(1);
    			config.set(newKey,
    					SecretUtil.decrypt((String) value, decryptKey, method));
    			config.addSecretKeyPath(newKey);
    			config.remove(key);
    		}
    	}
    }
    

    这里就对应了为什么加密项的
    key
    需要以
    *
    开头

至此,相关的疑惑是不是都得到解答了,你们对
DataX
的敏感信息加解密是不是完全懂了?

640 (4)

总结

  1. DataX
    目前只支持
    RSA
    加解密,不支持
    3DES
    ,也不支持其他加解密算法

    DataX
    的加密算法结合了
    RSA

    BASE64
    ,而非只用
    RSA
    ,也就是通用的
    RSA
    工具生成的密码不能用于
    DataX

  2. FrameWork
    有解密过程,但
    密钥

    密文
    需要使用者自己生成,配置过程有好几步,大家别漏了

    2.1 获取
    公钥

    私钥
    ,并指定
    keyVersion
    ,配置到
    .secret.properties

    2.2 在
    job.json
    中配置
    job.setting.keyVersion
    ,其值与 2.1 的
    keyVersion
    值一致

    2.3
    job.json
    中敏感配置项,
    key

    *
    开头,
    value

    明文
    经过
    SecretUtil#encryptRSA
    得到的密文

前言

相信不少同学都有欧阳这种情况,年初的时候给自己制定了一份关于学习
英语

源码
的详细年度计划。但是到了实际执行的时候因为各种情况制定的计划基本都没有完成,年底回顾时发现年初制定的计划基本都没完成。痛定思痛,第二年年初决定再次制定一份学习
英语

源码
的详细年度计划,毫无疑问又失败了。

经过多年的摸索,对于如何查看源码欧阳终于有了一些自己的心得。有的同学还想问英语有什么心得没,不要问,再问欧阳哭给你看。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

一看源码就头晕

网上有一种说法是从头开始看,假如源码是一个线团,那么找到线团的头子,顺着头子向下捋就能将源码了解的七七八八了。

这种方法对于查看小项目的源码是没有问题,因为小项目的分支逻辑不多,复杂度也不高,顺着线团的头子向下捋确实能够搞清楚整个项目。

但是对于vue这种大型项目就不适用了,大型项目里面的分支逻辑特别多,而且每个分支的复杂度也很高。大型项目的源码就像是一棵树,那么我们找的线团的头子只是这棵树的根节点。

看源码的时候你从树的根节点向下走,下面有多个子节点。选了一个子节点接着向下走,结果发现这个子节点下面又有多个子节点。再次选了一个子节点向下走,结果发现还是有很多子节点,重复几次后你可能就把自己给搞的头晕了。

出现这个问题的原因是你查看源码的时候没有一个明确的目标,因为大型项目的源码分支流程是超级多的。没有明确的目标一头扎进源码中就会迷失在源码的海洋中,这个明确的目标就是我们查看vue源码要搞清楚的问题。

如何接手一个复杂的新项目

想想你平时接手一个不熟悉并且很复杂的新项目你会怎么办?

我的做法是先查看项目的
README.md
,了解项目是如何运行起来的。

然后再查看项目的目录,对整个项目的结构有一点了解。

接着就是找到了解项目的测试或者产品,让他给我讲讲项目的大体流程和重要概念,这个时候可能听不懂或者听了就忘了,没关系,有个印象就可以了。

最后就是接到项目的迭代需求,从需求对应的代码出发开始了解项目。等需求做得足够多时,基本就将整个项目的代码过了一遍,此时我们已经完全接手这个项目了。

像接手新项目一样搞清楚源码

我们这里以vue举例,vue其实也是一个普通的js项目。本质上和我们的工作中接手的新项目没什么区别,对于查看vue源码我们也可以复用上面这个套路。

第一步:查看
contributing.md
文件

查看源码的
contributing.md
文件,这个文件就像是我们项目中的
README.md
。开源项目希望更多的人参与进来所以一般都会有个
contributing.md
文件。这个文件里面会教你源码项目是如何跑起来,项目结构是什么样的,怎么参与进来开发。

第二步:查看源码结构

第二步和接手新项目是一样的,查看vue源码的目录,让你对整个vue源码的结构有一些了解。

第三步:对源码大体流程和重要概念有初步印象

到了第三步就有问题了,不可能叫尤大充当测试或者产品的角色过来给我讲vue的大体流程和重要概念吧?此时我们可以换个思路,网上有很多讲解vue源码的文章或者书籍。可以让这些源码文章或者书籍充当测试和产品的角色。通过阅读这些vue源码文章或者书籍,你就能对vue的大体流程和重要概念有了初步的印象。

网上的文章都参差不齐,如何挑选出优质文章呢?

以掘金为例,你就在掘金上面去搜索vue源码。然后找出时间最近的,点赞和收藏最多的文章,这样找出来的文章基本都是优质且未过时文章(墙裂推荐欧阳的vue源码文章)。

当然如果你在之前没有接触过vue源码,第一次看vue源码文章或者书籍可能看不懂或者比较吃力。没关系,我们这一步只是让你对vue源码有个初步的印象就可以了。

第四步:带着问题去debug源码

直到这一步之前我们所做的事情都是让自己对源码有个大致的印象,最终想要看得懂源码还是得要自己上手去debug。

做项目时我们是通过不断的做业务需求,从而了解整个项目。在vue源码这里就是从一个你想要了解的具体问题出发,通过debug调试vue源码将这个问题搞清楚。这个问题就是我们在查看源码时的目标,和这个问题不相关的源码全部都忽略。

这种情况你带着问题去debug查看源码,此时的源码对于你来说就不是一棵树了,而是围绕着这个问题的一条线。我们的目标也很单纯,只是将这条线上面的源码搞清楚就行了。当你把这个问题搞清楚了后,在你的脑子里面关于vue源码就有一条线了。

还有一个进阶玩法,将“
通过debug源码把某个问题搞清楚的过程
”用自己的话说出来,这就形成了一篇优秀的源码文章,
欧阳的所有源码文章都是这样写出来的

每个问题在我们脑子里都是一条关于vue源码的一条线,当我们搞清楚足够多问题时,这些线连到一起就形成了一棵vue源码树。

看到这里有的小伙伴就有疑问了,那么问题又从哪里来啊?

我们每天写代码就在用vue,vue提供了很多黑魔法,难道你对这些黑魔法不感兴趣吗?

举个例子,在vue的文档中有写
defineProps
是一个宏函数。所以我们使用他的时候不需要从vue中
import
导入,那么你有没有好奇过为什么他不需要从vue中
import
导入呢?

为了搞清楚这个问题,我们需要先找到线团的线头子。而这个线头子毫无疑问就是
@vitejs/plugin-vue
插件,vue文件就是由这个插件处理的。给这个线头子打上断点,顺着断点向下走,只关心和
defineProps
相关的代码。最终我们就找到在一个
compileScript
函数中,会将源代码中的
defineProps
宏函数给remove掉,并且同时会生成一个
props
属性,由于
defineProps
宏函数经过编译后已经被remove掉了,所以就不需要从vue中
import
导入。

我们知道vue是一个编译时和运行时同时存在的框架,编译时说白了就是代码运行在nodejs阶段,运行时代码跑在在浏览器中。所以在debug源码的时候有时是在编译时进行,有时是在运行时进行。

在接下来的文章中我们会给你讲一些编译时和运行时debug源码的小技巧,如果你有更好用的技巧欢迎在评论区留言。

编译时debug源码小技巧

想要在编译时debug源码,首先我们需要启动一个debug终端。这里以
vscode
举例,打开终端然后点击终端中的
+
号旁边的下拉箭头,在下拉中点击
Javascript Debug Terminal
就可以启动一个
debug
终端。
debug-terminal

在debug终端执行对应的启动命令,比如
yarn dev
,断点将会停留在我们打断点的代码处。此时会有这样一排操作按钮,如下图:
step

上面的一排操作按钮欧阳平时debug源码时一般就使用了前四个,分别是:Continue(继续)、Step Over(单步跳过)、Step Into(单步调试)、Step Out(单步跳出)。

  • 第一个按钮Continue(继续):点击这个按钮后会让代码执行到下一个断点。

  • 第二个按钮Step Over(单步跳过):执行到下一条语句,如果下一条语句是函数,
    不会走进
    函数内部。

  • 第三个按钮Step Into(单步调试):执行到下一条语句,如果下一条语句是函数,
    将会走进
    函数内部。

  • 第四个按钮Step Out(单步跳出):跳出当前函数内部,断点将会走到外部调用当前函数的地方。

不一定每个问题你都能找到对应的线头子,这时你就不知道从哪里开始打断点了。比如还是
defineProps
宏函数,假如你不知道应该从
@vitejs/plugin-vue
插件开始打断点,那这种情况我们应该怎么办呢?

答案很简单,在源码中去搜索
defineProps
关键字,将搜索到的结果都打上断点。然后启动项目,发现代码走进了我们打的断点中,如下图:
call-stack

此时左侧的Call Stack调用栈就能派上用场了,他里面存了当前函数的所有调用栈。比如当前断点是停留在
processDefineProps
函数中,从Call Stack调用栈我们就能知道这个函数就是由
compileScript
调用的,而
compileScript
函数又是由
resolveScript
函数调用的。并且可以通过点击函数名就可以跳转到对应的函数中,并且恢复当时的上下文。

整个Call Stack调用栈是一条线,我们要找的问题的线头子就在这条线中。我们带着问题去debug源码的时候只需要将在Call Stack调用栈中,线头子后面的一系列函数逻辑搞清楚就行了。

运行时debug源码小技巧

大家都知道vue文件经过编译后会变成js文件,那么如何找到编译后的js文件给他打上断点呢?
network

很简单在network面板中找到对应的请求,这里我想找的是
index.vue
文件。然后右键,在弹出的菜单中选择第一个Open In Sources Panel。浏览器将会切换到source面板中,并且自动打开编译后的
index.vue
文件,然后我们就可以在这个文件中给对应的代码打断点。

关于Continue、Step Over这几个按钮,还有Call Stack调用栈都是和编译时是一样的,在这里我们就不赘述了,欢迎补充其他小技巧。

总结

大型项目的源码可以理解为是一棵树,如果我们直接从树的根节点开始去看源码肯定会被源码的各种分支逻辑搞的头晕。此时我们可以换个思路,按照以下四步去查看源码:

  • 查看源码的
    contributing.md
    文件,这个文件里面会教你源码项目是如何跑起来,项目结构是什么样的,怎么参与进来开发。

  • 通过查看源码目录让你对源码结构有个初步的印象。

  • 通过查看源码文章或者书籍让你对源码大体流程和重要概念有初步印象。

  • 带着你想要了解的问题去debug调试源码,和问题不相关的源码全部忽略掉。此时的源码就不再是一棵树,而是一条线,我们只需要将这条线的源码搞清楚就行了。当我们搞清楚足够多问题时,这些线将会汇聚成一棵树。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

创建园子,是人生的最大押注,相信只要专心为开发者服务,一定会有出路。

二十年的专注,如今除了园子一无所有,却要在2024年第三季度一掷孤注,尽一切可能让这块伴随众多开发者成长的热土被保住。

时间的脚步一刻不停留,将园子推到命运的关口,如果这个季度再不解决资金的缺口,园子将无路可走。

2023年在多方救助力量的帮助下,园子惊险地活了下来,有园友的捐助、购买会员与投资,有天使投资人的资助与借款,非常感谢2023年所有帮助过园子的朋友。

到2023年底会员总数只有3490,没能补上园子的资金缺口,只能靠银行贷款的资金维持周转,每个月还了再借出来。

今年5月当我们开始以
众包平台
作为园子商业化突破口时,意外的悲剧来袭,几乎所有渠道的贷款持续断流(还款后借不出来或者被降额),逼着园子必须从7月开始刻不容缓地自救。

有人说可以采取极端方式快速带来收入,如果遇到困难就放弃二十年的坚持,那这种坚持还有什么意义。

有人说可以向精通赚开发者钱的领先网站学习,学了别人的花招,丢了自己的灵魂,我们真的做不到。

我们宁愿选择也许被人瞧不起、被嘲笑为卖惨的自救方式——向园友向开发者求救。

生死关头免不了一场赌,求救也是一种赌,押上为开发者服务了二十年的园子,赌有足够多的开发者愿意出手相救。

如果输了,那就回家务农,做一个名副其实的不后悔的码农。

如果赢了,那就起死回生焕发青春,带给大家一个强大的园子。

人生就是不断选择的过程,二十年前选择了为开发者服务,二十年后在园子最困难的时候,又做出了一个选择——选择向开发者求救——求开
博客园会员
,救下园子,保住这块开发者的天地。

大家好,我是码农先森。

我之前待过一个做 ToB 业务的公司,主要是研发以会员为中心的 SaaS 平台,其中涉及的子系统有会员系统、积分系统、营销系统等。在这个 SaaS 平台中有一个重要的角色「租户」,这个租户可以拥有一个或多个子系统的使用权限,此外租户还可以使用平台所提供的开放 API 「即 OpenApi」来获取相关系统的数据。有了 OpenApi 租户可以更便捷的与租户自有系统进行打通,提高系统之间数据的传输效率。那么这一次实践的主要内容是 OpenApi 的授权设计,希望对大家能有所帮助。

我们先梳理一下本次实践的关键步骤:

  • 给每一个租户分配一对 AppKey、AppSecret。
  • 租户通过传递 AppKey、AppSecret 参数获取到平台颁发的 AccessToken。
  • 租户再通过 AccessToken 来换取可以实际调用 API 的 RefreshToken。
  • 这时的 RefreshToken 是具有时效性,目前设置的有效期为 2 个小时。
  • 针对 RefreshToken 还会提供一个刷新时效的接口。
  • 只有 RefreshToken 才有调用业务 API 的真实权限。

有些朋友对 AccessToken 和 RefreshToken 傻傻分不清,疑问重重?我在最开始接触这个设计的时候也是懵逼的,为啥要搞两个,一个不也能解决问题吗?确实搞一个也可以用,但大家如果对接过微信的开放 API 就会发现他们也是有两个,此外还有很多大的开放平台也是采用类似的设计逻辑,所以存在即合理。

这里我说一下具体的原因,AccessToken 是基于 AppKey 和 AppSecret 来生成的,而 RefreshToken 是通过 AccessToken 交换得来的。并且 RefreshToken 具备有效性,需要通过一个刷新接口,不定时的刷新 RefreshToken。RefreshToken 的使用是最频繁的,在每次的业务 API 调用是都需要进行传输,传输的次数多了那么 RefreshToken 被劫持的风险就会变大。假设 RefreshToken 真的被泄露,那么损失也是控制在 2 个小时以内,为了减低损失也还可以调低有效时间。总而言之,网络的传输并不总是能保证安全,AccessToken 在网络上只需要一次传输「即换取 RefreshToken」,而 RefreshToken 需要不断的在网络的传输「即不断调用业务 API」,传输的次数越少风险就越低,这就是设计两个 Token 的根本原因。

话不多说,开整!

按照惯例,我们先对整个目录结构进行梳理。这次的重点逻辑主要是在控制器 controller 的 auth 中实现,包含三个 API 接口一是生成 AccessToken、二是通过 AccessToken 交换 RefreshToken,三是刷新 RefreshToken。中间件 middleware 的 api_auth 是对 RefreshToken 进行解码验证,判断客户端传递的 RefreshToken 是否有效。此外,AccessToken 和 RefreshToken 的生成策略都是采用的 JWT 规则。

[manongsen@root php_to_go]$ tree -L 2
.
├── go_openapi
│   ├── app
│   │   ├── controller
│   │   │   ├── auth.go
│   │   │   └── user.go
│   │   ├── middleware
│   │   │   └── api_auth.go
│   │   ├── model
│   │   │   └── tenant.go
│   │   ├── config
│   │   │   └── config.go
│   │   └── route.go
│   ├── go.mod
│   ├── go.sum
│   └── main.go
└── php_openapi
│   ├── app
│   │   ├── controller
│   │   │   ├── Auth.php
│   │   │   └── User.php
│   │   ├── middleware
│   │   │   └── ApiAuth.php
│   │   ├── model
│   │   │   └── Tenant.php
│   │   └── middleware.php
│   ├── composer.json
│   ├── composer.lock
│   ├── config
│   ├── route
│   │   └── app.php
│   ├── think
│   ├── vendor
│   └── .env

ThinkPHP

使用 composer 创建 php_openapi 项目,并且安装 predis、php-jwt 扩展包。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/php_openapi
[manongsen@root php_openapi]$ composer create-project topthink/think php_openapi
[manongsen@root php_openapi]$ cp .example.env .env

[manongsen@root php_openapi]$ composer require predis/predis
[manongsen@root php_openapi]$ composer require firebase/php-jwt

使用 ThinkPHP 框架提供的命令行工具 php think 创建控制器、中间件、模型文件。

[manongsen@root php_openapi]$ php think make:model Tenant
Model:app\model\Tenant created successfully.

[manongsen@root php_openapi]$ php think make:controller Auth
Controller:app\controller\Auth created successfully.

[manongsen@root php_openapi]$ php think make:controller User
Controller:app\controller\User created successfully.

[manongsen@root php_openapi]$ php think make:middleware ApiAuth
Middleware:app\middleware\ApiAuth created successfully.

在 route/app.php 文件中定义接口的路由。

<?php
use think\facade\Route;

Route::post('auth/access', 'auth/accessToken');
Route::post('auth/exchange', 'auth/exchangeToken');
Route::post('auth/refresh', 'auth/refreshToken');

// 指定使用 ApiAuth 中间件
Route::group('user', function () {
    Route::get('info', 'user/info');
})->middleware(\app\middleware\ApiAuth::class);

从下面这个控制器 Auth 文件可以看出有 accessToken()、exchangeToken()、refreshToken() 三个方法,分别对应的都是三个 API 接口。这里会使用 JWT 来生成 Token 令牌,然后统一存储到 Redis 缓存中。其中 accessToken 的有效时间通常会比 refreshToken 长,但在业务接口的实际调用中使用的是 refreshToken。

<?php

namespace app\controller;

use app\BaseController;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use app\model\Tenant;
use think\facade\Cache;
use think\facade\Env;

class Auth extends BaseController
{
    /**
     * 生成一个 AccessToken
     */
    public function accessToken()
    {
        // 获取 AppKey 和 AppSecret 参数
        $params = $this->request->param();
        if (!isset($params["app_key"])) {
            return json(["code" => 400, "msg" => "AppKey参数缺失"]);
        }
        $appKey = $params["app_key"];
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "AppKey参数为空"]);
        }

        if (!isset($params["app_secret"])) {
            return json(["code" => 400, "msg" => "AppSecret参数缺失"]);
        }
        $appSecret = $params["app_secret"];
        if (empty($appSecret)) {
            return json(["code" => 400, "msg" => "AppSecret参数为空"]);
        }

        // 在数据库中判断 AppKey 和 AppSecret 是否存在
        $tenant = Tenant::where('app_key', $appKey)->where('app_secret', $appSecret)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "AppKey或AppSecret参数无效"]);
        }

        // 生成一个 AccessToken
        $expiresIn = 7 * 24 * 3600; // 7 天内有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 签发者 可以为空
            "aud" => "tenant",    // 面向的用户,可以为空
            "iat" => $nowTime,    // 签发时间
            "nbf" => $nowTime,    // 生效时间
            "exp" => $nowTime + $expiresIn,  // AccessToken 过期时间
        ];
        $accessToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        $scope = $tenant->scope;
        $data = [
            "access_token" => $accessToken, // 访问令牌
            "token_type"   => "bearer",     // 令牌类型
            "expires_in"   => $expiresIn,   // 过期时间,单位为秒
            "scope"        => $scope,       // 权限范围
        ];

        // 存储到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("ACCESS_TOKEN_PREFIX"), $accessToken), $appKey, $expiresIn);

        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }

    /**
     * 通过 AccessToken 换取 RefreshToken
     */
    public function exchangeToken()
    {
        // 获取 AccessToken 参数
        $params = $this->request->param();
        if (!isset($params["access_token"])) {
            return json(["code" => 400, "msg" => "AccessToken参数缺失"]);
        }
        $accessToken = $params["access_token"];
        if (empty($accessToken)) {
            return json(["code" => 400, "msg" => "AccessToken参数为空"]);
        }

        // 校验 AccessToken
        $redis = Cache::store('redis')->handler();
        $appKey = $redis->get(sprintf("%s.%s", Env::get("ACCESS_TOKEN_PREFIX"), $accessToken));
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "AccessToken参数失效"]);
        }

        $tenant = Tenant::where('app_key', $appKey)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "AccessToken参数失效"]);
        }

        $expiresIn = 2 * 3600; // 2 小时内有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 签发者, 可以为空
            "aud" => "tenant",    // 面向的用户, 可以为空
            "iat" => $nowTime,    // 签发时间
            "nbf" => $nowTime,    // 生效时间
            "exp" => $nowTime + $expiresIn,  // RefreshToken 过期时间
        ];
        $refreshToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        // 颁发 RefreshToken
        $data = [
            "refresh_token" => $refreshToken, // 刷新令牌
            "expires_in"    => $expiresIn,    // 过期时间,单位为秒
        ];

        // 存储到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken), $appKey, $expiresIn);

        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }

    /**
     * 刷新 RefreshToken
     */
    public function refreshToken()
    {
        // 获取 RefreshToken 参数
        $params = $this->request->param();
        if (!isset($params["refresh_token"])) {
            return json(["code" => 400, "msg" => "RefreshToken参数缺失"]);
        }
        $refreshToken = $params["refresh_token"];
        if (empty($refreshToken)) {
            return json(["code" => 400, "msg" => "RefreshToken参数为空"]);
        }

        // 校验 RefreshToken
        $redis = Cache::store('redis')->handler();
        $appKey = $redis->get(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken));
        if (empty($appKey)) {
            return json(["code" => 400, "msg" => "RefreshToken参数失效"]);
        }

        $tenant = Tenant::where('app_key', $appKey)->find();
        if (is_null($tenant)) {
            return json(["code" => 400, "msg" => "RefreshToken参数失效"]);
        }

        // 颁发一个新的  RefreshToken
        $expiresIn = 2 * 3600; // 2 小时内有效
        $nowTime = time();
        $payload = [
            "iss" => "manongsen", // 签发者 可以为空
            "aud" => "tenant",    // 面向的用户,可以为空
            "iat" => $nowTime,    // 签发时间
            "nbf" => $nowTime,    // 生效时间
            "exp" => $nowTime + $expiresIn,  // RefreshToken 过期时间
        ];
        $newRefreshToken = JWT::encode($payload, $tenant->app_secret, "HS256");

        $data = [
            "refresh_token" => $newRefreshToken, // 新的刷新令牌
            "expires_in"    => $expiresIn,       // 过期时间,单位为秒
        ];

        // 将新的 RefreshToken 存储到 Redis
        $redis = Cache::store('redis')->handler();
        $redis->set(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $newRefreshToken), $appKey, $expiresIn);

        // 删除旧的 RefreshToken
        $redis->del(sprintf("%s.%s", Env::get("REFRESH_TOKEN_PREFIX"), $refreshToken));
        return json_encode(["code" => 200, "msg"=>"ok", "data" => $data]);
    }
}

启动 php_openapi 服务。

[manongsen@root php_openapi]$ php think run
ThinkPHP Development server is started On <http://0.0.0.0:8000/>
You can exit with `CTRL-C`
Document root is: /home/manongsen/workspace/php_to_go/php_openapi/public
[Wed Jul  3 22:02:16 2024] PHP 8.3.4 Development Server (http://0.0.0.0:8000) started

使用 Postman 工具在 Header 上设置 Authorization 参数「即 RefreshToken」便可以成功的返回数据。

Gin

使用 go mod init 初始化 go_openapi 项目,再使用 go get 安装相应的第三方依赖库。

[manongsen@root ~]$ pwd
/home/manongsen/workspace/php_to_go/go_openapi
[manongsen@root go_openapi]$ go mod init go_openapi

[manongsen@root go_openapi]$ go get github.com/gin-gonic/gin
[manongsen@root go_openapi]$ go get gorm.io/gorm
[manongsen@root go_openapi]$ go get github.com/golang-jwt/jwt/v4
[manongsen@root go_openapi]$ go get github.com/go-redis/redis

在 Gin 中没有类似 php think 的命令行工具,因此需要自行创建 controller、middleware、model 等文件。

在 app/route.go 路由文件中定义接口,和在 ThinkPHP 中的使用差不多并无两样。

package app

import (
	"go_openapi/app/controller"
	"go_openapi/app/middleware"

	"github.com/gin-gonic/gin"
)

func InitRoutes(r *gin.Engine) {
	r.POST("/auth/access", controller.AccessToken)
	r.POST("/auth/exchange", controller.ExchangeToken)
	r.POST("/auth/refresh", controller.RefreshToken)

	// 指定使用 ApiAuth 中间件
	user := r.Group("/user/").Use(middleware.ApiAuth())
	user.GET("info", controller.UserInfo)
}

同样在 Gin 的控制器中也是三个方法对应三个接口。

package controller

import (
	"fmt"
	"go_openapi/app/config"
	"go_openapi/app/model"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/golang-jwt/jwt"
)

// 生成一个 AccessToken
func AccessToken(c *gin.Context) {
	// 获取 AppKey 和 appSecret 参数
	appKey := c.PostForm("app_key")
	if len(appKey) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "AppKey参数为空",
		})
		return
	}

	appSecret := c.PostForm("app_secret")
	if len(appSecret) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "appSecret参数为空",
		})
		return
	}

	// 在数据库中判断 AppKey 和 appSecret 是否存在
	var tenant *model.Tenant
	dbRes := config.DemoDB.Model(&model.Tenant{}).
		Where("app_key = ?", appKey).
		Where("app_secret = ?", appSecret).
		First(&tenant)
	if dbRes.Error != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	// 生成一个 AccessToken
	expiresIn := int64(7 * 24 * 3600) // 7 天内有效
	nowTime := time.Now().Unix()

	jwtToken := jwt.New(jwt.SigningMethodHS256)
	claims := jwtToken.Claims.(jwt.MapClaims)
	claims["iss"] = "manongsen"         // 签发者 可以为空
	claims["aud"] = "tenant"            // 面向的用户,可以为空
	claims["iat"] = nowTime             // 签发时间
	claims["nbf"] = nowTime             // 生效时间
	claims["exp"] = nowTime + expiresIn // AccessToken 过期时间
	accessToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	scope := tenant.Scope
	data := map[string]interface{}{
		"access_token": accessToken, // 访问令牌
		"token_type":   "bearer",    // 令牌类型
		"expires_in":   expiresIn,   // 过期时间,单位为秒
		"scope":        scope,       // 权限范围
	}

	// 存储 AccessToken 到 Redis
	config.RedisConn.Set(fmt.Sprintf("%s.%s", config.ACCESS_TOKEN_PREFIX, accessToken), tenant.AppKey, time.Second*time.Duration(expiresIn)).Result()
	c.JSON(http.StatusOK, gin.H{
		"code": 200,
		"msg":  "ok",
		"data": data,
	})
}

// 通过 AccessToken 换取 RefreshToken
func ExchangeToken(c *gin.Context) {
	// 获取 AccessToken 参数
	accessToken := c.PostForm("access_token")
	if len(accessToken) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "AccessToken参数为空",
		})
		return
	}

	// 校验 AccessToken
	appKey, err := config.RedisConn.Get(fmt.Sprintf("%s.%s", config.ACCESS_TOKEN_PREFIX, accessToken)).Result()
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}
	if len(appKey) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "AccessToken参数失效",
		})
		return
	}

	var tenant *model.Tenant
	dbRes := config.DemoDB.Model(&model.Tenant{}).
		Where("app_key = ?", appKey).
		First(&tenant)
	if dbRes.Error != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	expiresIn := int64(2 * 3600) // 2 小时内有效
	nowTime := time.Now().Unix()

	jwtToken := jwt.New(jwt.SigningMethodHS256)
	claims := jwtToken.Claims.(jwt.MapClaims)
	claims["iss"] = "manongsen"         // 签发者 可以为空
	claims["aud"] = "tenant"            // 面向的用户,可以为空
	claims["iat"] = nowTime             // 签发时间
	claims["nbf"] = nowTime             // 生效时间
	claims["exp"] = nowTime + expiresIn // RefreshToken 过期时间
	refreshToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	// 颁发 RefreshToken
	data := map[string]interface{}{
		"refresh_token": refreshToken, // 刷新令牌
		"expires_in":    expiresIn,    // 过期时间,单位为秒
	}

	// 存储到 Redis
	config.RedisConn.Set(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken), appKey, time.Second*time.Duration(expiresIn))
	c.JSON(http.StatusOK, gin.H{
		"code": 200,
		"msg":  "ok",
		"data": data,
	})
}

// 刷新 RefreshToken
func RefreshToken(c *gin.Context) {
	// 获取 RefreshToken 参数
	refreshToken := c.PostForm("refresh_token")
	if len(refreshToken) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "RefreshToken参数为空",
		})
		return
	}

	// 校验 RefreshToken
	appKey, err := config.RedisConn.Get(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken)).Result()
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
	}
	if len(appKey) == 0 {
		c.JSON(http.StatusOK, gin.H{
			"code": 400,
			"msg":  "AccessToken参数失效",
		})
		return
	}

	var tenant *model.Tenant
	dbRes := config.DemoDB.Model(&model.Tenant{}).
		Where("app_key = ?", appKey).
		First(&tenant)
	if dbRes.Error != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	// 颁发一个新的  RefreshToken
	expiresIn := int64(2 * 3600) // 2 小时内有效
	nowTime := time.Now().Unix()

	jwtToken := jwt.New(jwt.SigningMethodHS256)
	claims := jwtToken.Claims.(jwt.MapClaims)
	claims["iss"] = "manongsen"         // 签发者 可以为空
	claims["aud"] = "tenant"            // 面向的用户,可以为空
	claims["iat"] = nowTime             // 签发时间
	claims["nbf"] = nowTime             // 生效时间
	claims["exp"] = nowTime + expiresIn // RefreshToken 过期时间
	newRefreshToken, err := jwtToken.SignedString([]byte(tenant.AppSecret))
	if err != nil {
		c.JSON(http.StatusOK, gin.H{
			"code": 500,
			"msg":  "内部服务错误",
		})
		return
	}

	data := map[string]interface{}{
		"refresh_token": newRefreshToken, // 新的刷新令牌
		"expires_in":    expiresIn,       // 过期时间,单位为秒
	}

	// 将新的 RefreshToken 存储到 Redis
	config.RedisConn.Set(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, newRefreshToken), appKey, time.Second*time.Duration(expiresIn))

	// 删除旧的 RefreshToken
	config.RedisConn.Del(fmt.Sprintf("%s.%s", config.REFRESH_TOKEN_PREFIX, refreshToken))
	c.JSON(http.StatusOK, gin.H{
		"code": 200,
		"msg":  "ok",
		"data": data,
	})
}

启动 go_openapi 服务。

[manongsen@root go_openapi]$ go run main.go
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /auth/access              --> go_openapi/app/controller.AccessToken (3 handlers)
[GIN-debug] POST   /auth/exchange            --> go_openapi/app/controller.ExchangeToken (3 handlers)
[GIN-debug] POST   /auth/refresh             --> go_openapi/app/controller.RefreshToken (3 handlers)
[GIN-debug] GET    /user/info                --> go_openapi/app/controller.UserInfo (4 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8001

使用 Postman 工具在 Header 上设置 Authorization 参数「即 RefreshToken」便可以成功的返回数据。

结语

工作中只要接触过第三方开放平台的都离不开 OpenApi,几乎各大平台都会有自己的 OpenApi 比如微信、淘宝、京东、抖音等。在 OpenApi 对接的过程中最首要的环节就是授权,获取到平台的授权 Token 至关重要。对于我们程序员来说,不仅要能对接 OpenApi 获取到业务数据,还有对其中的授权实现逻辑要有具体的研究,才能通晓其本质做到一通百通。这次我分享的是基于之前公司做 SaaS 平台一些经验的提取,希望能对大家有所帮助。最好的学习就是实践,大家可以手动实践一下,如有需要完整实践代码的朋友可在微信公众号内回复「1087」获取对应的代码。


欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

论文基于实验验证,为数据需求预测这一问题提供了比较有用的建议,详情可以直接看看Conclusion部分。

来源:晓飞的算法工程笔记 公众号

论文: How Much More Data Do I Need? Estimating Requirements for Downstream Tasks

Introduction


给定一个小的训练数据集和一个学习算法,需要多少数据才能达到目标性能?这个问题在自动驾驶或医学成像等数据收集难的场景中至关重要。高估数据需求可能会因不必要的收集、清理和标注而产生成本,而低估意味着必须在后期收集更多数据,导致未来成本和工作流程延迟。

最近提出的
neural scaling laws
的相关工作表明,
Power Law
函数可以基于已有数据集拟合模型性能曲线,这也意味着能够将其应用到目标性能的数据需求预测中。

论文对多个函数进行性能拟合发现,如图1所示,当使用小数据集进行预测时,拟合函数可能会以不同的方式偏离实际的性能曲线。更重要的是,即使是预测精度的小误差也会导致高估或低估数据需求的大误差,这可能会带来巨大的运营成本。

为此,论文基于多个计算机视觉任务,系统地研究了包含
Power Law
在内的一系列回归函数,以便更好地估计数据需求。实验结果表明,多个回归函数都非常适合模型性能估计,这意味着对于所有情况都没有唯一的最佳回归函数。此外,结合用于调整的校正因子以及多轮收集方案,可以显着提高数据需求估计的性能。基于论文的指南,从业者可以准确估计机器学习系统的数据需求,从而节省开发时间和数据采集成本。

总而言之,论文通过实验为实际应用中的数据收集提供了易于实施的指南:

  • 不同预测方法估计的数据比需要的多得多或少得多,使用多轮数据收集和
    under-estimate
    方案可以收集高达
    90%
    的真实所需数据量。
  • 通过已有的任务的采集模拟,可以确定哪些方法低估了数据需求,并学习一个校正因子来解决这个缺陷。使用校正因子并进行最多五轮数据收集,最多可以收集到任何目标性能所需的最小数据量的一到两倍。
  • 在只剩下一轮数据收集,可以使用所有的回归函数来获得一个真实数据要求的区间界限,可以指导建模者根据实际需求或多或少地积极收集数据。

Main problem


The data collection problem

定义
\(z\sim p(z)\)
是从分布
p
中抽样的数据,例如
\(z := (x, y)\)
对应于图像
x
和标签
y
。考虑一个预测问题,目前有一个初始训练数据集
\(\mathcal{D}_0 := \{z_i\}^{n_0}_{i=1}\)
和一个模型
f
。定义
\(V_f(\mathcal{D})\)
为模型在集合
\(\mathcal{D}\)
上的训练评分函数,目标是获得预定的目标分数
\(V^* > V_f(\mathcal{D}_0)\)

为了实现目标,采样
\(\hat{n}\)
个额外的数据
\(\hat{\mathcal{D}} := \{\hat{z}_i\}^{\hat{n}}_{i=1}\sim p(z)\)
,然后评估
\(V_{f}(\mathcal{D}_{0}\cup \hat{\mathcal{D}})\)
。如果没达到目标,就必须增加更多的额外数据。因为每个数据都会产生收集、清理和标记的成本,所以达到目标增加的额外数据
\(\hat{n}\)
越小越好。此外,由于启动一轮数据收集本身也很耗时,所以最多只能进行
T
轮。在
T
轮内达不到目标要求,就是没有解决问题。

数据收集问题可总结为:对于初始化
\(\hat{\mathcal{D}} = \varnothing\)
,重复进行如下迭代:

  • 估计需要的额外数据量
    \(\hat{n}\)
  • 采样数据
    \(|\hat{\mathcal{D}}| = \hat{n}\)
    ,然后评估分数:
    • 如果
      \(V_f(\mathcal{D}_0\cup \hat{\mathcal{D}}) ≥ V^*\)
      ,则终止。
    • 否则,重复另一轮迭代,最多进行
      T
      轮。

数据收集问题的目标是在
T
轮内选择满足
\(V_f(\mathcal{D}_0\cup \hat{\mathcal{D}}) ≥ V^*\)
的最小
\(\hat{n}\)
,而论文重点关注循环的第一步:准确估计满足
\(V^∗\)
所需的
\(\hat{n}\)

Regressing performance using data set size

根据先前研究的经验设定的,设定了如图2所示的数据预测和采集流程。先前的研究发现,对于递增的数据集
\(\mathcal{D}_0 \subset\mathcal{D}_1\subset \cdots\)
以及
\(n_i=|\mathcal{D}_i|\)
,训练出来的模型的准确率满足以下单调递增的凹函数:

从上述经验可知,随着收集数据的越多,额外数据带来的收益逐渐减少。此外,可以使用单调递增的凹函数对
\(v(n)\)
进行回归建模。

表1中的四个函数为满足上述经验的候选项。虽然可以使用更复杂的模型,但这些具有少量参数的简单函数更适合样本较少的场景(指用于拟合的数据较少)。通过拟合的回归函数,可以求解最小的
\(\hat{n}\)
,使得
\(\hat{v}(n_0 + \hat{n}; \theta)\ge V^∗\)

整体的逻辑如算法1所示:

  • 选择
    r
    个子集
    \(\mathcal{S_0}\subset \mathcal{S_1}\subset\cdots\subset \mathcal{S}_{r−1} = \mathcal{D}_0\)
    ,计算它们的分数来创建一个回归数据集
    \(\mathcal{R}:=\{|\mathcal{S}_i|, V_f(\mathcal{S}_i)\}^{r-1}_{i=0}\)
  • 从表1中选择一个函数
    \(\hat{v}(n; \theta)\)
    进行数据收集循环,直到准确率满足要求:
    • 通过最小二乘法最小化拟合参数
      \(\theta\)
    • 根据
      \(\hat{v}(\hat{n}; \theta^*)\ge V^∗\)
      取最小的额外数据量
      \(\hat{n}\)
    • 收集
      \(\hat{n}\)
      个新数据。

    • \((|\mathcal{D}_0| + |\hat{\mathcal{D}}|, V_f (\mathcal{D}_0 \cup \hat{\mathcal{D}}))\)
      扩充拟合数据
      \(\mathcal{R}\)

现有的研究表明,
Power Law
可以根据数据集大小来估计模型精度,但实际应用时存在以下三大挑战。

  1. All of the functions in Table 1 fit the model score

当有足够的初始数据,表1的回归函数都可以准确拟合模型性能曲线
\(v(n)\)
。如图1所示,当初始化数据集满足
\(|\mathcal{D}_0| = 600,000\)
张图像(≈ 50% 的数据集)时,每个函数的预测准确度与实际的误差最多为6%。尽管
Power Law
是根据理论推导选择的,但还没有足够的实验证明其的确优于其他函数。

  1. Extrapolating accuracy with small data sets is hard

在数据有限的情况下,所有回归函数都无法很好地推断
\(v(n)\)
。如图1所示,当初始化数据集满足
\(|\mathcal{D}_0| = 125,000\)
张图像(≈ 10% 的数据集)时,部分函数的预测准确度偏离了实际准确率。

需要注意的是,部分函数的准确率比
Power Law
要好。在之前的研究中也观察到了这种小数据的情况,提出对数据集和模型大小进行联合回归的解决方案。虽然这样做能提高性能,但需要通过采样数据子集和修改不同模型来获得2倍大小的
\(\mathcal{R}\)
,这在计算上变得昂贵且耗时。因此,论文专注于使用少量训练统计数据的简单估计器,即
\(r \le 10\)

  1. Small accuracy errors yield large data errors

假设在ImageNet上构建一个满足67%测试集准确率的模型,大概需要用到900000张图像。尽管使用包含600,000张图像拟合的估计函数的误差
\(| 67\% − \hat{v}(900,000; \theta)|\)
很小(1%-6%),但其预估达到67%性能所需的图像数量的跨度却很大(120,000-310,000),仅1%的性能预测误差就会导致额外多收集34%的数据。由于预测误差的容忍度很低,所以必须确定估计数据需求的最佳实践。

Empirical findings


Data and methods

论文在表2的图像分类、对象检测和语义分割任务上测试数据收集问题,不同任务类型训练不同的网络:图像分类训练的
ResNets
,2D目标检测训练的
SSD300
,3D目标检测训练的
FCOS3D
,语义分割训练的
Deeplabv3
,BEV分割训练的
Lift Splat
架构。对于每个任务,模型的架构和学习算法,包括数据采样进行了少量修改,详细内容可以去看看附录。

在进行实验之前需要做以下准备:

  • 构造拟合数据集
    :对于每个数据集和任务,有一个初始数据集
    \(\mathcal{D}_0\)
    ,在分析时根据
    \(\mathcal{D}_0\)
    与完整数据集的相对大小记录
    \(n_0\)
    。根据算法1创建回归数据集
    \(\mathcal{R}\)
    ,以线性增长的大小(
    \(|S_i|=|\mathcal{D}_0(i+1)/r|\)
    )采样
    r
    个子集,其中
    \(r\le 10\)
    以确保开销足够小。
  • 构造GT
    :为了在预测性能和估计数据需求方面评估回归函数,采样多个子集
    \(\mathcal{D}_1\subset \mathcal{D}_2\subset .· ·\)
    (相对于完整训练数据集的10%、20%、30%、... 、 100%的子集)。对于每个子集,训练模型并评估分数
    \(\mathcal{V}_f(\mathcal{D}_i)\)
    。基于子集的数据量和分数,构建分段线性评分函数
    \(v(n)\)
    并将其用作GT。

论文进行两种类型的实验:

  • 使用
    \(\mathcal{R}\)
    拟合表1的回归函数,然后评估其对
    \(|\mathcal{D}_i|\ge|\mathcal{D}_0|\)
    的预测分数相对于
    \(\mathcal{V}_f(\mathcal{D}_i)\)
    的误差,可以揭示每个回归函数是否可以很好地推断模型对更大数据集的得分。
  • 使用
    \(n_0 = 10\%\)
    进行初始化(对于VOC,
    \(n_0 = 20\%\)
    )并在
    \(T = 1、3、5\)
    轮内估计需要多少数据来获得不同的目标分数
    \(V^*\)
    。整体流程跟算法1类似,不同之处在于模拟中不会真的采样更多数据并训练评估
    \(\mathcal{V}_f (\mathcal{D}_0\cup \hat{\mathcal{D}})\)
    ,而是直接用前面
    构造GT
    中获得的GT函数
    \(v(n_0 + \hat{n})\)
    直接输出模型分数。这样的模拟近似于真实的数据收集问题,同时简化了实验,不必反复重新训练模型。

Analysis

  • Regression.

表3总结了每个回归函数在预测更大数据集的分数时的均方根误差(RMSE),每项测试中使用不同的随机种子执行3次,主要有以下与前两个挑战相关的发现:


  1. \(n_0\ge 50\%\)
    时,每个函数都能达到低RMSE,并且有至少一个回归函数的RMSE小于1。

  2. \(n_0=10\%\)
    时,大多数函数会产生高RMSE。这表明当拟合小数据集时,这些函数容易偏离真实的模型性能曲线
    \(v(n)\)
  3. 对于大多数数据集,候选的回归函数能够始终产生低RMSE,特别是
    Arctan
    函数,在分类任务上表现非常出色。

这些结果表明:

  1. 从小数据集预测更大数据集的模型性能是较为困难的。
  2. Power Law
    之外的其他回归函数的分数回归可能会更准确。
  • Simulation.

给定初始数据集
\(n_0\)
和数据收集轮数
\(T\)
,通过求解一系列目标
\(V^*\)
来模拟每个回归函数的数据收集能力。图3展示了每个函数收集的最终数据与根据GT性能曲线对应的最小数据的比率,即(
\(n_0 + \hat{n})/(n_0 +n^∗)\)
,其中
\(n^∗\)
是满足
\(v(n_0 +n^∗) = V^∗\)
的真实最小数据需求量。

在评估每个回归函数如何收集数据时,需要考虑两种情况:

  1. 如果比率小于1,则该函数是乐观预测,低估了需要多少数据,也意味着无法在
    T
    轮内收集到足够的数据来满足
    \(V^*\)
  2. 如果比率大于 1,则该函数是悲观预测,高估了需要多少数据。理想的数据收集策略,其最小比率应该大于1。实验表明,
    Arctan
    函数是最悲观的,经常出现较大幅度的最大比率。

这个实验验证了第三个挑战,低回归误差函数并不一定转化为更好的数据收集。在
CIFAR100

ImageNet

VOC
上,使用
Arctan
可能会导致收集比实际需要多五倍的数据,而在
nuScenes
上进行
BEV
分割则可能会导致超过
10
倍的数据收集。

尽管表3显示
Arctan
在所有函数中实现了最低的
RMSE
(3.19),但使用它来估计数据需求会导致过多的额外数据收集。在确定良好的数据收集策略时,仅分析回归误差是不够的,需要论文提出的模拟方法。

对于大多数回归函数,需要迭代多轮才能收集到足够的数据。当
\(T = 1\)
时,
Power Law

Logarithmic

Algebraic Root
都低估了除
VOC
之外的所有数据集和任务的数据需求。而当
\(T = 5\)
时,对于除
CIFAR10
之外的每个数据集,所有函数在
\(V^∗\)
的整个范围内都能采集到实际所需的90%数据以上。

但是当
\(V^∗\)
很大时,即使
\(T=5\)
,这些估计器仍然会低估数据需求。虽然这样估计的成本不高,但并未能解决数据采集问题。需要对这些估计量进行纠正,从而更好地指导数据收集。

Towards better estimates of data


从前面的模拟实验可以看到,乐观的预测无法收集足够的数据来满足
\(V^*\)
,而悲观预测则导致收集的数据远远超过所需的数据。所以论文引入一个校正因子,用于解决低估数据需求的问题。

A correction factor to help meet the target

在算法1的每一轮数据收集中,需要根据
\(\hat{v}(n_0 + \hat{n}; \theta^∗) \ge V^∗\)
最小化
\(\hat{n}\)
。然而,前面的模拟实验表明,大多数回归函数是乐观的,低估了需要数据量。一种简单的纠正少估数据的方法是添加校正因子
\(\tau \ge 0\)
,估计满足校正的更高目标
\(V^* + \tau\)
所需的数据量。因此,固定一个常数
\(\tau\)
并修改算法1,根据根据
\(\hat{v}(n_0 + \hat{n}; \theta^∗) \ge V^∗ + \tau\)
最小化
\(\hat{n}\)

为了确定校正因子的值,将其当作超参数进行拟合。假设现有完整的
CIFAR10
数据集,想为未来的数据集构建一个
T
轮收集策略:

  • 使用每个回归函数模拟
    CIFAR10

    \(\tau = 0\)
    的数据收集量,获得图3的曲线。
  • 增加
    \(\tau\)
    直到该函数的整个比率曲线高于1,即收集到足够的数据来满足
    CIFAR10
    的所有目标值
    \(V^*\)
  • 使用这个拟合的
    \(\tau\)
    作为未来数据集的校正因子。

通过将校正因子与多轮数据收集相结合,就可以始终收集到略高于最低数据要求的数据。表4比较了
\(\tau\)
对每个回归函数在各数据集上的最小比率的影响。如果不进行校正,
Power Law

Logarithmic

Algebraic Root
都低估了除
VOC
之外的所有数据集和任务的数据需求。而使用
\(\tau\)
之后,这些函数几乎都实现
1

2
之间的比率。

此外,对于每个数据集,这三个回归函数在
\(T = 5\)
时都达到各自的最低比率(高于
1
)。如图4所示,
Power Law

Logarithmic

Algebraic Root
在每个数据集上都实现了
1.03

2.5
之间的比率。需要注意的是,所有数据集都没有统一最佳回归函数。由于
Arctan
本身就高估了数据需求,所以这个函数并没有从校正中受益。

所以,从模拟实验中可以看出,修正后的
Power Law

Logarithmic

Algebraic Root
搭配
\(T=5\)
,能够满足预期目标的同时最小化收集的总数据量。

Empirical bounds on the data requirement

如果校正因子拟合不佳或收集轮数
\(T\)
有限,仍可能低估或高估数据要求。从表4中可以看出,在
\(T = 1\)

nuScenes
分割任务模拟中,没有
\(\tau\)

Power Law
仅估计所需数据的58%,而使用
\(\tau\)
则导致估计的数据比需要的多25倍。

因此,在某些应用中,建模者可能还需要根据经验估计应该收集的数据量。假设在最后一轮的采集估计必须满足数据收集目标,此时就需要查看最坏和最好情况的采集估计,即上限和下限。不同的回归函数会产生不同的预测,其中最大的预测值是最坏情况的估计,最小的预测值就是最好情况的估计。

对于每个数据集,设置
\(T = 1\)
和不同的初始数据集
\(n_0\)
进行实验,使用八个回归函数对不同
\(V^*\)
进行数据需求估计。图5的上行展示了在不同
\(n_0\)
基础上,所有
\(V^*\)
所需的真实数据量在函数集预测值的上界和下界之间的概率,下行则进一步展示了上界和下界与真实值比例平均值。

结果表明,基于足够初始数据集的单轮预测能够准确估计数据需求的上限和下限。此外,即使需要多轮才能收集到比较足够的数据,我们也能在最后一轮获得需求的上下区间。所以,在实际应用中,上下界限可以指导建模者根据任务的情况做出乐观或悲观的选择。

Conclusion


虽然模型性能预测受到越来越多关注,但论文发现性能估计并不能完成解决数据需求。即使是性能预测的小误差,也会导致数据收集中的大误差,这意味着一个好的数据估计器允许的误差远小于直觉所想的。此外,性能预测误差分为低估或高估,都会对数据收集造成不同的挑战。为了更好地分析数据收集策略,论文制定了迭代数据收集的模拟方案。通过实验,论文得出了几个高层次的见解:

  • 不同预测方法估计的数据比需要的多得多或少得多,使用多轮数据收集和
    under-estimate
    方案可以收集高达
    90%
    的真实所需数据量。
  • 通过已有的任务的采集模拟,可以确定哪些方法低估了数据需求,并学习一个校正因子来解决这个缺陷。使用校正因子并进行最多五轮数据收集,最多可以收集到任何目标性能所需的最小数据量的一到两倍。
  • 在只剩下一轮数据收集,可以使用所有的回归函数来获得一个真实数据要求的区间界限,可以指导建模者根据实际需求或多或少地积极收集数据。



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.