2025年1月

背景

最近半路接手了一个系统的优化需求,这个系统有个遗留问题还没解决,随着新需求的上线,系统正式开放使用,这个遗留问题也必须解决。

这个系统大概是下面这样的,支持录入各种数据源的信息(ip、端口、数据库种类、账号密码等):

image-20250104210901249

录入完成后,可以查看这些数据源中的表、表的ddl、表中的列(列名、类型及注释等),也可以查看各个表中的数据。

其中一个数据源,是sql server 2008版本,总是连接失败,更别提获取这个db中的表了。

错误堆栈如下:

image-20250104211502284

定位过程

1、前期处理

在我做新需求的时候,我之前的同事A已经处理过这个问题。这个问题只在线上出现,因为开发测试环境压根没有这么老的数据库版本,在开发测试环境申请一台windows服务器来安装一个这样的老版本数据库,也比较麻烦;所以,同事A在处理的时候,基本是网上查到修改的办法后,直接弄到线上去试试能不能解决。

之前改过两次,第一次是这样:

1、参考附件脚本《配置文件java.security》,修改/usr/local/jdk/bin/java/java.security中的配置项jdk.tls.disabledAlgorithms。
修改内容:删除jdk.tls.disabledAlgorithms配置项的“TLSv1, TLSv1.1”,替换成“DHE”

简单解释下这部分的修改,从前文中的错误堆栈来看,这个问题是和ssl有关系的,我之前猜想的就是,这个sql server和mysql一样,支持使用tls加密传输,保护数据安全;但是,可能sql server 2008版本太老了,不支持tls 1.2/1.3这些,只能使用tls1.1/tls1.0等,但是呢,jdk认为使用tls1.1/1.0不够安全,默认是禁用了的,所以,只要把这个禁用tls1.1/tls1.0的配置给改改,允许jdk使用tls1.1/tls1.0,不就可以连接sql server 2008了吗?

但是,遗憾的是,这个改动之前已经上线试过了,没有生效,还是报错。

另外,在我做新需求的时候,同事A又试了一个改动,把驱动版本升级了下,大家知道java都是使用jdbc去连接数据源的,各个厂商会实现jdbc,之前呢,使用的是sqlserver的4.0版本的驱动,这把,直接弄到了8.4.1,准备搞上去再试试:

image-20250104213141696

2、尝试修改配置禁用加密

我了解到这个情况后,因为需求也比较赶,就没想花大力气来搞这个bug,我们都是内网服务器之间调用,这个加密传输,我感觉不是必要,直接弄成不加密不就行了吗?

我找了下代码,里面有拼接jdbc url的代码:

image-20250104213746728

那直接去掉这个
encrypt=true;trustServerCertificate=true
,去掉后,本地测试了下连接sql server数据库(不是2008版本),用wireshark抓包看了下,发现客户端发给数据库(sql server常用1433端口)的报文里,还是说加密是开启的:

image-20250104214148592

行吧,我查了下,原来不指定encrypt属性,默认就是true,那我手动指定成false得了。

image-20250104214411744

又抓包看了下,这次有了变化,客户端发过去的报文是说,不使用加密:

image-20250104214503660

服务端返回报文也说,不加密:

image-20250104214556331

但是,我在后续的报文里,发现还是部分加密了的:

image-20250104214722668

这就有得难以理解了。

3、debug驱动代码

所以这时候的思路就是,看看为什么源码里还会加密传输,那只能debug了,看看是不是还有其他选项在控制这块,后面找到如下代码:

image-20250104215227828

在上图中,先是三次握手,再是prelogin(就是前文抓包看到的那部分,如:Encryption: Encryption is available but off (0)),再下来呢,有个if,如果满足这个if,就会开启SSL,此时,就会导致发出去的报文是ssl的,也就是说,只要走了这个if,我们就绕不开ssl,就规避不了这个bug。

那我们看看,怎么绕开这个if吧。

这个if中,左边是个常量,ENCRYPT_NOT_SUP表示不支持加密,
image-20250104215651186

右边是个变量,初始化的时候是:

private byte negotiatedEncryptionLevel = TDS.ENCRYPT_INVALID;

后续,什么地方会修改这个变量呢,是在prelogin部分,在处理数据库返回的prelogin响应报文时:

image-20250104215857867

这里,2812行,是直接取响应报文中的值,也就是说,以数据库服务端的为准。

还记得,服务端一般是返回如下值:0。

image-20250104220012549

那这样的话,就会导致那个if条件为true:

image-20250104220149407

这块就有点难办了,这个值是服务端返回的,除非数据库返回ENCRYPT_NOT_SUP,表示不支持加密,否则,这个加密是跑不掉了。但我没太想过要让数据库去改这个配置,毕竟这个库,说是客户端还不少,我不可能去动它,影响太大,可能到时候导致别的客户端要改造。

还有个方向,是通过客户端的传参,去影响服务端的返回值,比如客户端传一个不支持加密,看看服务端的返回值。

但,比较遗憾的是,客户端驱动定死了,只能传下面这两个值,要么ENCRYPT_ON,要么ENCRYPT_OFF:

image-20250104221111123

4、覆盖驱动源码,强行绕过enableSSL方法

当时,我的想法是,把这个if条件改一下,改成:

if (TDS.ENCRYPT_ON == negotiatedEncryptionLevel){
    tdsChannel.enableSSL(serverInfo.getServerName(), serverInfo.getPortNumber());
}

其实,按我这会的想法,改下面这个地方也不错,想办法传ENCRYPT_NOT_SUP给服务端:

requestedEncryptionLevel = isBooleanPropertyOn(sPropKey, sPropValue) ? TDS.ENCRYPT_ON : TDS.ENCRYPT_OFF;

如何才能修改驱动包的代码呢,改是改不了,但是可以想办法覆盖,方法就是在项目中建同包名同类名的java文件(内容直接从源码文件拷贝),然后修改其中的部分代码即可。

但这次有点意思的是,遇到个以前没见过的问题,报如下错误:

java.lang.SecurityException: signer information does not match signer information

最终在网上查了下,(
https://blog.csdn.net/weixin_44070655/article/details/129922513),错误的意思是,我们新建的java文件的一些签名信息不太匹配,这块没细看。最终是需要删除jar包中的如下两个文件:

image-20250104223152701

删除的方式,可以直接用压缩软件打开,删除里面的这两个文件即可,另存为即可。然后把改后的jar包发布到私服(可以修改下坐标),或者是使用maven的如下方式:

image-20250104223331747

最终成功绕过enableSSL了,抓包发现,客户端确实没对包进行加密了,但是,服务端不返回任何报文了。我理解的是,服务端当初在进行prelogin协商时,返回的加密选项是:ENCRYPT_OFF,这个按正常流程,后续就是需要加密的,我们现在强行改了客户端源码,导致服务端陷入了迷思:wtf,客户端怎么回事,怎么没加密,这个客户端有问题?行吧,我不返回了。

最终,我还是放弃了这条路。因为,我上网查了下这个encrypt选项。

https://learn.microsoft.com/zh-cn/sql/connect/jdbc/understanding-ssl-support?view=sql-server-ver16

image-20250104224440595

原来,加密分为两个部分,第一个部分是登录部分,建立连接时,会传输用户名密码,我当时发现:在我上面强行改了客户端驱动,收不到服务端响应时,进行了抓包,发现我可以看到明文密码,当时我也有点惊讶,也反应过来了,难怪要弄ssl加密呢;第二个部分是,后续的数据的加密,如传输的sql语句和执行结果。

当encrypt为true时,两个部分都会加密;而当encrypt为false时,登录部分还是会加密,而数据部分不会加密。

所以,不管怎么说,登录部分总是要加密的,所以我还是不要挑战这条路了,毕竟这是协议规定好了的。

5、增加ssl日志

最终,把修改源码部分,全都回退了。最终的jdbc url选项如下,驱动版本也保留着

;encrypt=false;trustServerCertificate=true;
<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <version>8.4.1.jre8</version>
</dependency>

说白了,ssl问题依然会有(毕竟encrypt=false,登录部分还是要走ssl),但是,我们可以想办法把ssl过程中的日志打印出来:

System.setProperty("javax.net.debug","ssl:handshake:verbose");

image-20250104225440075

这个呢,会打印ssl过程中的细节(注意,是打印到标准输出的,日志文件里没有,要看看启动java进程时,把标准输出重定向到哪里去了,不能是 > /dev/null这种),类似下面这种,到时候我们上线了再看看日志情况吧:

image-20250104225659003

6、上线后检查ssl日志

这个问题,现在说白了,就是客户端发了ssl握手消息给服务端,正常来说,服务端是要响应的,像下面这样,返回server hello这个报文,其中包含选定的ssl加密套件、服务端证书链等信息:

image-20250105080602411

然后我预期的是,上线后,这个ssl日志能把服务端报错的原因打印出来,结果并没有。

直接就是说,服务端关闭了连接,终止握手:

image-20250105080917662

从后来我找运维抓的包也能看出来,服务端发了tcp关闭的报文:

image-20250105081152690

7、尝试更换客户端驱动版本

此时,有点陷入僵局了。客户端没日志,网络报文也看不出来,那意思是只能看看服务端有没有日志了吧。

然后去找了dba,我现场演示了下,他看了数据库端的日志文件:啥都没有。

他给了我两个方案,一个是这个库太老,后续会复制一个新库出来,这个要等;再一个是,这个库也有其他的项目在用,也是java客户端的,他说帮我问下相关同事。

然后后续我单独加了那个同事,了解了下,他们用的驱动版本是7.4.1,我们目前线上是8.4.1:

<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <version>7.4.1.jre8</version>
</dependency>

然后,jdbc的url是这样:

image-20250105082000028

最终我们的方向是,换不同版本的驱动看一下,先试上面这个7.4.1.jre8版本。因为之前我也查到过资料,就是xx版本的数据库,需要xx版本的驱动。

https://learn.microsoft.com/zh-cn/sql/connect/jdbc/microsoft-jdbc-driver-for-sql-server-support-matrix?view=sql-server-ver16

image-20250105082313608

从图里能看出来,sql server 2008,需要7.2版本的驱动。我们之前的8.4.1,肯定是高了;其实看上图,7.4也高了,但不知道人家项目为啥能行,就也试试呗。

8、开发测试驱动版本工具类

写了个类来测试:

image-20250105082729476

执行方式就是把jar和class放到同一目录下执行:

[root@news-center-app ~]# ll DbConnectTest.class mssql-jdbc-7.4.1.jre8.jar 
-rw-rw-rw- 1 root root    1631 Dec 30 15:30 DbConnectTest.class
-rw-rw-rw- 1 root root 1209660 Dec 30 15:16 mssql-jdbc-7.4.1.jre8.jar
[root@news-center-app ~]#  java -classpath .:./mssql-jdbc-7.4.1.jre8.jar DbConnectTest "jdbc:sqlserver://1.1.1.1:1433;databaseName=xxx;encrypt=false;trustServerCertificate=true" zhangsan xxx

这样呢,方便我们替换驱动的jar包。

结果呢,运维说必须走流程才能这么玩,理由就是不能在生产上做测试,battle了半天,后面还是提流程了(正好有个小需求又要上,就把这个工具一起弄上去了)。

上线的时候,我们顺便就把之前的驱动版本从8.4.1.jre8改成了7.4.1.jre8,也包括这个小工具。

上线后,以为这次肯定能行,结果,还是报一样的错误,此时正值周五快下班的时候,我无语了:就不能早点解决了这个bug,好好过个周末,不然还牵挂着它。

结果我回家路上,运维在群里说,bug可以了,他上网找了下文章:

https://blog.csdn.net/zhujun300/article/details/141434867

还是修改jdk的java.security文件,这次又把另一个被禁用的给去掉了:3DES_EDE_CBC. (我在开头说,同事A之前就改过一次,但是去的是tls1.1/tls1.0,没去这个3DES_EDE_CBC)。

然后,就好了。

行吧,还是能好好过周末的。

9、为什么去掉3DES_EDE_CBC能好

网上翻了很多资料,没找到讲这块原理的。我自己本地试验了下,在去掉这个3DES_EDE_CBC前,记录了打印的ssl日志;在去掉后,又记录了下。

对比如下,可以看到,去掉后,握手消息中多了很多加密套件(其中都包含了3DES_EDE_CBC这个加密算法):

image-20250105084822878

那这样的话,我们可以认为,线上那个库,应该是不支持客户端发送出去的所有加密套件,才把ssl握手终止了。

而加上3DES_EDE_CBC后,多了一些加密套件,而这些套件,正好服务端就支持,所以就可以了。

当然,具体选择了哪个加密套件,可能得下周上班了再找运维看看日志或者抓个包瞧瞧才知道。

这次呢,我也学会了一个新技能,由于ssl握手消息是封装在其他协议(TDS)里面的,在wireshark中都没法看。

image-20250105085534815

上面蓝色部分就是握手消息,但看不了,要知道握手的具体细节,非得看ssl日志才行,这个让人有点不爽。

我上网找了下,还真找到个网站:

https://williamlieurance.com/tls-handshake-parser/

只要把十六进制流复制进去,就能解析ssl。

image-20250105085747745

image-20250105085817985

对我来说,算是不小的一个收获。

10、怎么查看sql server 2008支持的加密套件

一开始,对这块不理解,以为ssl加密相关能力是sql server 2008这个软件提供的(对windows服务器太不了解了),但后来查了些资料发现,ssl加密相关能力是操作系统提供的;像是linux呢,一般就是安装了openssl,其他软件都是复用openssl的能力。

而sql server 2008,当时查了下版本:

Microsoft SQL Server 2008 (SP1) - 10.0.2531.0 (X64)   Mar 29 2009 10:11:52   Copyright (c) 1988-2008 Microsoft Corporation  Enterprise Edition (64-bit) on Windows NT 5.2 <X64> (Build 3790: Service Pack 2) (VM) 

没细问windows服务器的版本,但我们从上述也能看出来:

windows服务器版本其实就是:Windows NT 5.2
(Build 3790: Service Pack 2)

这个服务器,搜索了下,其实是:win server 2003版本。

image-20250105100603157

难怪了,这个操作系统版本太低了,估计就是很多ssl套件都不支持。

那么,我们如何查看一个windows电脑,支持哪些加密套件呢?

有以下几种方式:

10.1 通过组策略管理器查看

  1. 按下 “Win + R” 键,输入 “gpedit.msc” 并回车,打开组策略编辑器2。
  2. 依次展开 “计算机配置”→“策略”→“管理模板”→“网络”→“SSL 配置设置”2。
  3. 双击右侧的 “SSL 密码套件顺序”,选择 “已启用”,在左下侧可以看到支持的 SSL 加密套件

10.2 通过命令查看

使用 PowerShell:以管理员身份运行 PowerShell,输入Get-TlsCipherSuite命令,可列出当前系统上配置的 TLS/SSL 加密套件以及它们的启用状态等信息。

image-20250105095527943

10.3 IISCrypto

这边下载了一个软件:
https://www.nartac.com/Products/IISCrypto/Download

可以大概看到ssl中涉及的几个部分:传输协议、加密算法、hash算法、秘钥交换算法。

image-20250105095223507

通过上述几个部分,组成了各种各样的ssl套件:

image-20250105095317399

官方参考链接

https://learn.microsoft.com/en-us/windows/win32/secauthn/schannel-cipher-suites-in-windows-vista

其他尝试过的定位手段

1、本地安装sql server 2008

我猜测这个问题应该是比较好复现的,只是苦于没有环境,然后就想着在本机装一个,没想到,就这也踩了好久的坑。

安装sql server 2008,依赖.net framework 3.5这个运行环境,我是win10系统。网上的方法分两类,在线安装和离线安装,整个.net framework 3.5包有100多m,在线安装,我这边反正不行,不只是网络的问题,好像在线安装是需要一个service正常运行才可以:windows update。

相信很多人,当初为了禁用windows的升级,可能把这个service都删掉了。

可能也正是因为这样,我甚至离线安装也是失败的。

我也附上几个链接吧,万一大家可以呢:

https://mp.weixin.qq.com/s/y0sZz8wtLtcPQM0sI8DHmQ

https://mp.weixin.qq.com/s/_unsXuBLH1JjtsprKYjSWw

https://mp.weixin.qq.com/s/4FtoTMF3L_hDAXGu_GgOdA

试这些的时候,也可以先看下官方帮助文档:

https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/enable-or-disable-windows-features-using-dism?view=windows-11

C:\WINDOWS\system32>dism /online /?
dism /online /Enable-Feature /?
dism /online /Add-Package /?

最终,我用上述这些方法也没成功,后来是按照如下文章来解决的:

https://blog.csdn.net/Roeluo/article/details/144692042

当然,这个.net framework 3.5装上了,并不影响我的sql server 2008安装失败,当然,现在bug都解决了,有空再弄吧。

参考链接

https://blog.csdn.net/wpf416533938/article/details/128573683

https://blog.csdn.net/tanhongwei1994/article/details/84957254

https://learn.microsoft.com/zh-cn/archive/blogs/jdbcteam/the-driver-could-not-establish-a-secure-connection-to-sql-server-by-using-secure-sockets-layer-ssl-encryption

https://stackoverflow.com/questions/32766114/sql-server-jdbc-error-on-java-8-the-driver-could-not-establish-a-secure-connect

https://stackoverflow.com/questions/79113822/java-1-8-sql-server-2008-r2-unable-to-run-query-when-encryption-is-activated

https://www.reddit.com/r/sysadmin/comments/u6grqv/very_legacy_ssl_problem_on_server_2003yep_it/

1. Python中字符串的相加和相乘

在Python中,字符串可以通过加号(
+
)进行相加(连接),也可以通过乘号(
*
)进行相乘(重复)。以下是这两种操作的详细说明和示例:

字符串的相加(连接)

字符串的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个字符串连接成一个单一的字符串。

str1 = "Hello"
str2 = "World"
result = str1 + " " + str2
print(result)  # 输出 "Hello World"

在连接多个字符串时,可以连续使用加号:

str1 = "Hello"
str2 = "World"
str3 = "Python"
result = str1 + " " + str2 + " " + str3
print(result)  # 输出 "Hello World Python"

字符串的相乘(重复)

字符串的相乘是通过使用乘号(
*
)运算符来实现的。它将一个字符串重复指定的次数。

str1 = "Hello"
result = str1 * 3
print(result)  # 输出 "HelloHelloHello"

如果乘以0,结果将是一个空字符串:

str1 = "Hello"
result = str1 * 0
print(result)  # 输出 ""

组合使用

你也可以将字符串的相加和相乘组合使用:

str1 = "Hello"
str2 = "World"
result = (str1 + " ") * 2 + str2
print(result)  # 输出 "Hello Hello World"

注意事项

  • 字符串的相加和相乘操作是不可逆的,即结果仍然是一个字符串,而不是一个列表或其他数据结构。
  • 在连接大量字符串时,使用
    join()
    方法通常更高效,因为它避免了创建多个中间字符串对象。
  • 字符串的相乘操作要求乘数是一个整数,否则会引发
    TypeError

通过这些操作,你可以灵活地处理字符串数据,满足不同的编程需求。


2. Python中元祖的相加和相乘

在Python中,元组(tuple)也可以通过加号(
+
)进行相加(连接),以及通过乘号(
*
)进行相乘(重复)。以下是对这两种操作的详细说明和示例:

元组的相加(连接)

元组的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个元组连接成一个单一的元组。

tuple1 = (1, 2, 3)
tuple2 = (4, 5, 6)
result = tuple1 + tuple2
print(result)  # 输出 (1, 2, 3, 4, 5, 6)

在连接多个元组时,可以连续使用加号:

tuple1 = (1, 2)
tuple2 = (3, 4)
tuple3 = (5, 6)
result = tuple1 + tuple2 + tuple3
print(result)  # 输出 (1, 2, 3, 4, 5, 6)

元组的相乘(重复)

元组的相乘是通过使用乘号(
*
)运算符来实现的。它将一个元组重复指定的次数。

tuple1 = (1, 2, 3)
result = tuple1 * 3
print(result)  # 输出 (1, 2, 3, 1, 2, 3, 1, 2, 3)

如果乘以0,结果将是一个空元组:

tuple1 = (1, 2, 3)
result = tuple1 * 0
print(result)  # 输出 ()

组合使用

你也可以将元组的相加和相乘组合使用:

tuple1 = (1, 2)
tuple2 = (3, 4)
result = (tuple1 + tuple2) * 2
print(result)  # 输出 (1, 2, 3, 4, 1, 2, 3, 4)

注意事项

  • 元组的相加和相乘操作是不可逆的,即结果仍然是一个元组,而不是其他数据结构。
  • 元组的相乘操作要求乘数是一个整数,否则会引发
    TypeError
  • 元组是不可变的,因此相加和相乘操作不会改变原始元组,而是创建一个新的元组。

通过这些操作,你可以灵活地处理元组数据,满足不同的编程需求。


3. Python中列表的相加和相乘

是的,Python中的列表(list)也可以通过加号(
+
)进行相加(连接),以及通过乘号(
*
)进行相乘(重复)。以下是对这两种操作的详细说明和示例:

列表的相加(连接)

列表的相加是通过使用加号(
+
)运算符来实现的。它将两个或多个列表连接成一个单一的列表。

list1 = [1, 2, 3]
list2 = [4, 5, 6]
result = list1 + list2
print(result)  # 输出 [1, 2, 3, 4, 5, 6]

在连接多个列表时,可以连续使用加号:

list1 = [1, 2]
list2 = [3, 4]
list3 = [5, 6]
result = list1 + list2 + list3
print(result)  # 输出 [1, 2, 3, 4, 5, 6]

列表的相乘(重复)

列表的相乘是通过使用乘号(
*
)运算符来实现的。它将一个列表重复指定的次数。

list1 = [1, 2, 3]
result = list1 * 3
print(result)  # 输出 [1, 2, 3, 1, 2, 3, 1, 2, 3]

如果乘以0,结果将是一个空列表:

list1 = [1, 2, 3]
result = list1 * 0
print(result)  # 输出 []

组合使用

你也可以将列表的相加和相乘组合使用:

list1 = [1, 2]
list2 = [3, 4]
result = (list1 + list2) * 2
print(result)  # 输出 [1, 2, 3, 4, 1, 2, 3, 4]

注意事项

  • 列表的相加和相乘操作是不可逆的,即结果仍然是一个列表,而不是其他数据结构。
  • 列表的相乘操作要求乘数是一个整数,否则会引发
    TypeError
  • 列表是可变的,因此相加和相乘操作不会改变原始列表,而是创建一个新的列表。

通过这些操作,你可以灵活地处理列表数据,满足不同的编程需求。


4. Python中的与或非

在Python中,与(AND)、或(OR)和非(NOT)是逻辑运算符,用于组合或修改布尔值(
True

False
)。以下是对这些逻辑运算符的详细说明和示例:

与(AND)

  • 功能

    and
    运算符用于逻辑与运算。当两个操作数都为
    True
    时,结果为
    True
    ,否则为
    False
  • 示例

    result = True and True  # True
    result = True and False  # False
    result = False and True  # False
    result = False and False  # False
    

或(OR)

  • 功能

    or
    运算符用于逻辑或运算。当两个操作数中至少有一个为
    True
    时,结果为
    True
    ,否则为
    False
  • 示例

    result = True or True  # True
    result = True or False  # True
    result = False or True  # True
    result = False or False  # False
    

非(NOT)

  • 功能

    not
    运算符用于逻辑非运算。它将布尔值取反,即
    True
    变为
    False

    False
    变为
    True
  • 示例

    result = not True  # False
    result = not False  # True
    

短路求值

Python的逻辑运算符具有短路求值的特性:

  • AND短路
    :如果第一个操作数为
    False
    ,则整个表达式的结果必定为
    False
    ,因此不会评估第二个操作数。

    result = False and some_function()  # 不会调用some_function()
    
  • OR短路
    :如果第一个操作数为
    True
    ,则整个表达式的结果必定为
    True
    ,因此不会评估第二个操作数。

    result = True or some_function()  # 不会调用some_function()
    

实际应用

这些逻辑运算符可以用于复杂的条件判断,例如:

age = 25
is_student = True

# 判断是否为成年学生
if age >= 18 and is_student:
    print("You are an adult student.")

# 判断是否为未成年或非学生
if age < 18 or not is_student:
    print("You are either a minor or not a student.")

通过这些逻辑运算符,你可以构建复杂的逻辑条件来控制程序的流程。


5. Python的赋值机制

赋值机制
这张图片描述了Python中基本类型(如整数)的赋值机制。在Python中,基本类型是不可变的,这意味着一旦创建,它们的值就不能被改变。图片中的三行代码展示了变量赋值和内存地址的变化过程:

Step 1

  • a = 100
    :创建了一个变量
    a
    ,并将其与值
    100
    关联。在内存中,
    100
    被存储在一个位置,我们称之为
    pos1(100)
  • b = a
    :创建了另一个变量
    b
    ,并将
    a
    的值赋给它。在Python中,这意味着
    b
    也指向内存中的同一个位置
    pos1(100)

Step 2

  • a = 200
    :将新的值
    200
    赋给变量
    a
    。由于基本类型是不可变的,所以不会修改内存中
    pos1(100)
    的位置。相反,
    a
    现在指向内存中的另一个位置
    pos2(200)
    。而
    b
    仍然指向原来的
    pos1(100)
    ,因为
    b
    的值没有被重新赋值。

Step 3

  • 这个步骤强调了可以使用
    id()
    函数来验证变量的内存地址。
    id()
    函数返回对象的内存地址,可以用来检查两个变量是否指向同一个对象。

这个过程说明了Python中变量赋值的机制,即变量实际上是对对象的引用。当一个变量被赋值给另一个变量时,它们都指向内存中的同一个对象。如果原始变量被赋予一个新的值,它将指向一个新的对象,而其他变量仍然指向原来的对象。

以下是这个过程的Python代码示例:

a = 100  # Step 1: a -> pos1(100)
print(id(a))  # 输出a的内存地址

b = a    # b也指向a的内存地址
print(id(b))  # 输出b的内存地址,应该与a相同

a = 200  # Step 2: a -> pos2(200), b仍然指向pos1(100)
print(id(a))  # 输出a的新内存地址
print(id(b))  # 输出b的内存地址,应该与最初的a相同

这段代码将展示
a

b
在内存中的地址变化,从而验证了上述的赋值机制。

进行联想,这样的赋值机制和在C、C++、Verilog中的有什么区别?

Python的赋值机制与C、C++和Verilog等语言有显著的不同,主要是因为这些语言在处理变量和内存管理方面的方式不同。以下是一些主要的区别:

1. 基本类型和对象引用

  • Python
    :Python中的变量总是指向对象的引用。对于基本类型(如整数、浮点数、字符串),Python使用一种称为“对象模型”的机制,其中每个值都是一个对象,变量存储的是对象的引用。这意味着即使两个变量指向同一个对象,修改其中一个变量的值不会影响另一个变量。
  • C/C++
    :在C和C++中,基本类型(如
    int

    float

    char
    )直接存储值。变量赋值实际上是值的复制。如果一个变量被赋值给另一个变量,它们各自独立存储自己的值副本。
  • Verilog
    :Verilog是一种硬件描述语言,用于设计和模拟数字系统。在Verilog中,变量的赋值可以是阻塞赋值(
    =
    )或非阻塞赋值(
    <=
    ),这影响赋值的执行顺序和时序。Verilog中的赋值更接近于硬件电路的行为。

2. 可变性

  • Python
    :Python中的不可变类型(如整数、浮点数、字符串)一旦创建,其值就不能改变。这意味着任何赋值操作实际上都是创建一个新的对象,并让变量指向这个新对象。
  • C/C++
    :C和C++中的变量可以直接修改其值,因为它们存储的是值本身。对于基本类型,赋值操作会直接改变变量的值。
  • Verilog
    :Verilog中的变量赋值涉及到信号的更新,这些更新可能在模拟的下一个时间步发生,具体取决于赋值类型(阻塞或非阻塞)。

3. 内存管理

  • Python
    :Python自动管理内存,包括垃圾回收。对象的生命周期由引用计数和垃圾回收器共同管理。
  • C/C++
    :C和C++要求程序员手动管理内存,包括分配和释放。这提供了更高的灵活性,但也增加了出错的风险。
  • Verilog
    :Verilog不涉及传统的内存管理概念,因为它用于描述硬件行为,而不是软件中的内存操作。

4. 赋值语句的执行

  • Python
    :赋值语句创建或修改对象引用。如果一个变量被赋值给另一个变量,它们最初指向同一个对象。
  • C/C++
    :赋值语句复制值到新的变量。如果一个变量被赋值给另一个变量,它们各自独立存储自己的值副本。
  • Verilog
    :赋值语句定义了信号之间的逻辑关系,这些关系在硬件实现中表现为电路连接。

总的来说,Python的赋值机制更侧重于对象的引用和不可变性,而C、C++和Verilog则更侧重于值的直接操作和硬件行为的描述。这些差异反映了不同语言的设计哲学和应用领域。

如果更加形象化地解释,如下:

让我们通过一个比喻来形象化地解释Python、C/C++和Verilog中的赋值机制:

Python:图书馆的图书卡片

想象一下,Python中的变量就像是图书馆里的图书卡片。每张卡片上写着一本书的标题和它在书架上的位置(内存地址)。当你创建一个变量并赋值时,就像你从书架上取出一本书,然后写一张卡片来记录这本书的信息。

  • 基本类型赋值
    :假设你取出了一本名为“Python编程”的书,你写了一张卡片“a”,上面写着“Python编程”和它的位置。当你把变量
    a
    赋值给变量
    b
    时,你只是又写了一张相同的卡片“b”,两张卡片都指向同一本书。如果你在卡片“a”上写下了另一本书的名字“高级Python”,这并不会影响到卡片“b”,因为它仍然指向原来的那本书“Python编程”。在Python中,这意味着
    b
    的值不会随着
    a
    的赋值而改变。

C/C++:图书馆的复印机

在C或C++中,变量就像是图书馆里的复印机。当你创建一个变量并赋值时,就像你复印了一本书的内容,并把复印件给了变量。

  • 基本类型赋值
    :如果你复印了“Python编程”这本书,并把复印件给了变量
    a
    ,然后你又复印了
    a
    的内容给了变量
    b
    ,那么
    a

    b
    都有了“Python编程”的副本。如果你改变了
    a
    的副本,比如在上面写下了“高级Python”,这并不会影响到
    b
    的副本,因为它们是独立的。在C/C++中,这意味着
    b
    的值不会随着
    a
    的赋值而改变。

Verilog:电路板上的连线

Verilog中的赋值更像是电路板上的连线。当你在Verilog中赋值时,你定义了电路板上不同组件之间的连接。

  • 赋值
    :如果你定义了一个信号
    a
    连接到一个LED灯,然后你定义了另一个信号
    b
    也连接到同一个LED灯,那么
    a

    b
    都会影响到LED灯的状态。如果你改变了
    a
    的值,这将直接影响到
    b
    ,因为它们是同一个电路的一部分。在Verilog中,这意味着
    b
    的值会随着
    a
    的赋值而改变,这取决于你使用的是阻塞赋值还是非阻塞赋值。

通过这个比喻,我们可以看到Python中的赋值更像是创建指向同一对象的引用,而C/C++中的赋值是创建值的副本,Verilog中的赋值则是定义电路组件之间的逻辑关系。这些不同的赋值机制反映了各自语言的特性和用途。

进一步联想,这和面向对象、面相过程的编程的区分有关系吗?

是有关系的,Python、C/C++和Verilog中的赋值机制与面向对象(Object-Oriented Programming, OOP)和面向过程(Procedure-Oriented Programming, POP)编程范式有着紧密的联系。这些编程范式影响了语言的设计和赋值机制的实现。让我们看看它们之间的联系:

面向对象编程(OOP)

  • Python
    :Python是一种多范式语言,支持OOP。在OOP中,数据和函数被封装在对象中,对象通过方法和属性相互交互。Python的赋值机制允许对象(包括基本类型)的引用被复制,而不是对象本身。这与OOP中对象的封装和隐藏原则相吻合,即对象的内部状态不应该被外部直接修改。

面向过程编程(POP)

  • C/C++
    :C是一种面向过程的语言,而C++支持POP和OOP。在POP中,程序是由一系列操作步骤(过程或函数)组成,数据通过参数在函数间传递。C/C++的赋值机制直接复制值,这与POP中函数间数据传递的方式相似,即函数调用时参数值被复制到函数内部。

硬件描述语言(HDL)

  • Verilog
    :Verilog是一种硬件描述语言,用于设计和模拟数字电路。它不直接属于OOP或POP范式,但它的赋值机制与硬件电路的行为紧密相关。Verilog中的赋值定义了电路组件之间的连接和信号流动,这与硬件设计中电路的物理连接相似。

赋值机制与编程范式的关系

  • 封装
    :在OOP中,封装是一个核心概念,它意味着对象的内部状态应该被保护起来,不被外部直接访问。Python的赋值机制支持这一点,因为变量赋值操作不会影响到其他指向同一对象的变量,除非使用特定的方法(如修改可变对象的属性)。

  • 数据流
    :在POP中,数据通过函数调用在程序中流动。C/C++的赋值机制反映了这种数据流,因为变量的值在函数间传递时会被复制。

  • 电路行为
    :Verilog的赋值机制模拟了硬件电路的行为,其中信号的赋值定义了电路组件之间的连接和数据流动。

总的来说,编程范式影响了语言的赋值机制和数据管理方式。OOP强调对象的封装和交互,POP侧重于函数和过程的调用,而硬件描述语言则模拟电路的物理行为。这些范式在不同语言中的体现,决定了它们如何处理变量赋值和数据管理。

更加形象地解释如下:

让我们通过比喻来形象地解释面向对象(OOP)和面向过程(POP)编程的区分,以及这两种编程范式在Python、C、C++中的应用:

面向对象编程(OOP):建筑团队

想象一下,你是一个建筑项目的负责人。在OOP中,每个对象就像是项目中的一个团队成员,他们各自有特定的职责和技能(属性和方法)。

  • Python
    :在Python中,OOP非常自然和直观。你可以将每个团队成员(对象)视为一个独立的实体,他们可以互相交流(方法调用)和协作(继承和多态)。例如,一个
    House
    类可以有
    build()

    paint()
    等方法,而
    Apartment
    类可以继承这些方法并添加特定的功能。

面向过程编程(POP):食谱和烹饪步骤

面向过程编程就像是按照食谱烹饪。食谱中的每个步骤(函数)都是独立的,你需要按照一定的顺序执行这些步骤来完成菜肴。

  • C
    :C语言主要是面向过程的。你可以将每个烹饪步骤(函数)视为一个独立的指令,它们按照特定的顺序执行。例如,一个程序可能首先调用
    prepare_ingredients()
    函数,然后是
    cook_meal()
    ,最后是
    serve_dinner()

  • C++
    :C++支持面向过程和面向对象编程。你可以将C++看作是食谱和建筑团队的结合。你仍然可以按照食谱(POP)的步骤来烹饪,但你也可以创建不同的食材(类)和烹饪工具(对象),它们可以有自己的特性和行为。

Python、C、C++中的OOP和POP区别

  • Python
    :在Python中,OOP是核心特性之一。你可以轻松地定义类和对象,以及它们之间的关系。Python的动态类型系统使得OOP更加灵活和强大。

  • C
    :C语言不支持OOP的概念。你不能定义类或对象,只能使用结构体(structs)来模拟一些OOP的特性,如封装。C语言更侧重于过程和函数的调用。

  • C++
    :C++是支持OOP的,它引入了类(class)的概念,允许你定义对象和它们的行为。C++也支持继承、多态和封装,这些都是OOP的关键特性。同时,C++也保持了C语言的面向过程特性,允许你使用函数和过程来组织代码。

形象比喻

  • OOP
    :就像是一个由多个专业团队组成的项目,每个团队(类)都有自己的专长和任务,他们可以独立工作,也可以与其他团队合作完成更大的项目。

  • POP
    :就像是一个详细的食谱,每一步(函数)都是独立的,你需要按照食谱的顺序来准备和烹饪食物。

通过这些比喻,我们可以更直观地理解OOP和POP编程范式的区别,以及它们在不同编程语言中的实现和应用。

附上另外一个案例

案例

这是小卷对分布式系统架构学习的第7篇文章,前面已经讲了很多理论知识,今天结合具体的中间件来讲分布式配置中心

1.面试官提问

面试官:假设你是公司的基础架构部门,现在需要设计内部的配置中心中间件,你要怎么设计?

我:设计客户端和服务端,客户端集成到业务项目中,项目启动时从服务端pull配置加载到本地,并且定时check服务端和本地配置是否一致,服务端如有更新,再pull到本地

面试官:那如果有几万台服务器,都是这样定时去check,服务端压力岂不是很大,要怎么解决呢?

我:那改成用服务端push的方式???

面试官:......

面试官:那今天就到这里吧,你回去等通知吧......

2.为什么需要分布式配置中心

不了解底层原理的小卷只好回家后苦心专研分布式配置中心的原理,一定要弄清楚底层逻辑,下次要吊打面试官。

先来简单理解为什么需要配置中心?

我们开发的服务都是单体架构时,配置文件就和代码放在一起,如springboot的application.yml文件,对配置的修改只需要修改这一个文件就行。到分布式服务中,一个服务会有多台机器,不可能每个机器都单独修改配置文件,然后重新部署的。

这就要用到配置中心了,以nacos为例,下图是配置修改时和服务器间的操作:

3.开源框架

这里列举4种分布式配置中心的中间件,我们直接从一个中间件的原理来学习配置中心。

工作这么多年,应该得了解一些开源组件,大大小小的都行:

1、Apollo

2016年5月,携程开源的配置管理中心,具备规范的权限、流程治理等特性。

GitHub地址:
https://github.com/apolloconfig/apollo

2、spring cloud config

2014年9月开源,Spring Cloud 生态组件,可以和Spring Cloud体系无缝整合。

3、Nacos

2018年6月,阿里开源的配置中心,也可以做DNS和RPC的服务发现。

4、Diamond

Diamond 出自淘宝,开源地址 【
https://github.com/takeseem/diamond】
,阿里集团内部的配置中心仍然用的diamond,只是开源版本不再维护

面试时可能会问到为什么选择Apollo作为配置中心?不用其他的配置中心呢?

很多人用的时候就是看别人也这么用,或者大家都这么用,就选择了这个中间件。这里如果遇到了的话,就可以提到开源社区的活跃性,因为Apollo 的社区生态活跃,且使用的公司特别多,常见的坑基本都被踩完了,所以选用Apollo。

4. Apollo工作原理

4.1基础模型

Apollo文档:
Apollo配置中心设计
,工作原理比较简单:

  1. 用户在配置中心对配置进行修改并发布
  2. 配置中心通知Apollo客户端有配置更新
  3. Apollo客户端从配置中心拉取最新配置,更新本地配置并通知到应用

4.2架构模块

解释下各个模块的功能:

  • Config Service提供配置的读取、推送等功能,服务对象是Apollo客户端
  • Admin Service提供配置的修改、发布功能,服务对象是Apollo Portal(管理界面)
  • Config Service和Admin Service都需要注册到Eureka并保持心跳;
  • Meta Server是对Eureka做了一层封装,封装的是服务发现接口;
  • Client通过域名访问Meta Server获取Config Service服务列表,即获取IP+端口,然后通过IP+端口访问服务,同时Client端自己做负载均衡,错误重试;
  • Portal访问Meta Server获取Admin Service服务列表,也是获取IP+端口,然后访问服务,Portal侧也做负载均衡;

5. 使用Apollo

官方有提供快速部署使用文档:
Quick Start

具体操作步骤可以自行查看官方文档,这里我们主要通过简单使用Apollo来理解配置中心。部署完成后,登陆Apollo的管理界面,然后创建个应用,发布后再创建个配置,接着再次发布,如下图:

这里我是在本地启动的,访问http://localhost:8080/可以查看已注册的实例

然后创建一个Springboot应用连接到Apollo配置中心,这里不写那么具体了,可以自行参考官方的
Java客户端使用指南

Mac电脑需先在本地的
/opt/settings/server.properties
文件中配置环境
env=DEV
,然后在
application.properties
文件中配置Apollo相关的内容如下:

# 接入Apollo配置
app.id=multi_function
apollo.meta=http://localhost:8080
# Apollo本地缓存路径
apollo.cache-dir=/Users/longbig/log
# 指定Apollo配置文件的环境
env=DEV
# 配置访问秘钥
apollo.accesskey.secret=4c61a00512ad4cc09ef8a0e1ee672d89
apollo.bootstrap.enabled=true

为了测试客户端接收到配置中心配置变更的事件,我们参考官方文档的代码写个监听器的代码如下:

@Configuration
@Slf4j
public class ApolloConfig {
    @Bean
    public void init() {
        Config config = ConfigService.getAppConfig();
        config.addChangeListener(new ConfigChangeListener() {
            @Override
            public void onChange(ConfigChangeEvent changeEvent) {
                log.info("Changes for namespace " + changeEvent.getNamespace());
                for (String key : changeEvent.changedKeys()) {
                    ConfigChange change = changeEvent.getChange(key);
                    log.info(String.format("Found change - key: %s, oldValue: %s, newValue: %s, changeType: %s", change.getPropertyName(), change.getOldValue(), change.getNewValue(), change.getChangeType()));
                }
            }
        });
    }
}

最后测试验证,在管理界面增加一个配置,然后对配置修改发布,可以看到客户端已经接收到配置变更的事件了,并且打印出日志信息了


6. 配置发布后实时生效设计

从上面简单使用中可以看到,配置发布后实时推送到客户端。下面我们简要看一下这块是怎么设计实现的

配置发布的大致过程:

  1. 用户在Portal操作配置发布
  2. Portal调用Admin Service的接口操作发布
  3. Admin Service发布配置后,发送ReleaseMessage给各个Config Service
  4. Config Service收到ReleaseMessage后,通知对应的客户端

6.1发送ReleaseMessage的实现方式

从上图看,应该是用MQ的方式比较合适,但是Apollo没有用外部消息中间件,而是通过数据库来实现这个简单的消息队列的。具体如下:

  1. Admin Service在配置发布后会往ReleaseMessage表插入一条消息记录,消息内容就是配置发布的AppId+Cluster+Namespace
  2. Config Service有一个线程会每秒扫描一次ReleaseMessage表,看看是否有新的消息记录
  3. Config Service如果发现有新的消息记录,那么就会通知到所有的消息监听器(
    ReleaseMessageListener
  4. 消息监听器得到配置发布的AppId+Cluster+Namespace后,会通知对应的客户端

我们查看数据库的
ReleaseMessage

ReleaseHistory
表,可以查看到当前消息和历史消息


6.2 Config Service通知客户端的实现方式

这里能解释说明开头的面试题,客户端更新配置是Pull还是Push的方式?

具体实现方式如下:

  1. 客户端会发起一个Http请求到Config Service的
    notifications/v2
    接口,也就是
    NotificationControllerV2
  2. NotificationControllerV2不会立即返回结果,而是通过
    Spring DeferredResult
    把请求挂起
  3. 如果在60秒内没有该客户端关心的配置发布,那么会返回Http状态码304给客户端
  4. 如果有该客户端关心的配置发布,NotificationControllerV2会调用DeferredResult的
    setResult
    方法,传入有配置变化的namespace信息,同时该请求会立即返回。客户端从返回的结果中获取到配置变化的namespace后,会立即请求Config Service获取该namespace的最新配置。

7. 客户端的工作原理

接着讲讲Apollo客户端的工作原理:

  1. 客户端和服务端保持了一个长连接,从而能第一时间获得配置更新的推送。(通过Http Long Polling实现)
  2. 客户端还会定时从Apollo配置中心服务端拉取应用的最新配置。
    • 这是一个fallback机制,为了防止推送机制失效导致配置不更新
    • 客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified
    • 定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property:
      apollo.refreshInterval
      来覆盖,单位为分钟。
  3. 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中
  4. 客户端会把从服务端获取到的配置在本地文件系统缓存一份
    • 在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置
  5. 应用程序可以从Apollo客户端获取最新的配置、订阅配置更新通知

8.题外话

之前在第一家公司工作过程中,遇到个问题是:对应用某个配置的变更如何通知到生产环境的所有机器?

当时的场景是前端发起HTTP请求,调用后端接口修改配置,因为负载均衡的缘故,请求只会打到1台机器上,只有1台机器的内存配置被更新,其他机器的内存配置还是旧的,当时小组一起讨论解决办法,可能认知有限,只想到MQ等等方式,没想到配置中心的原理

后来去了阿里之后,参与过写配置中心配置变更监听器,实现了全量机器的内存配置更新功能

现在回想起来,当时没解决的原因还是认知不够,现在学了配置中心的原理又想到了这件事,分享给大家学习参考~

相信通过学习Apollo配置中心的原理,你在面试过程中如果遇到开头的题目,应该也能说上一二了。

Open HTTP Redirect

HTTP 重定向(HTTP Redirect Attack)是一种网络,利用 HTTP 协议中的重定向机制,将用户引导至恶意网站或非法页面,进而进行钓鱼、恶意软件传播等恶意行为。攻击者通常通过操控重定向响应头或 URL 参数实现这种

HTTP 重定向基本原理

HTTP 重定向是一种用于通知客户端(如浏览器)请求的资源已被移动到另一个位置的机制,通常由服务器发送 3xx 系列状态码响应。常见的重定向状态码包括:

  • 301 Moved Permanently
    :永久重定向,表示请求的资源已被永久移动到新的 URL。

  • 302 Found
    :临时重定向,表示请求的资源临时在另一个 URL 上。

  • 303 See Other
    :建议客户端使用 GET 方法获取资源。

  • 307 Temporary Redirect
    :临时重定向,保持请求方法不变。

  • 308 Permanent Redirect
    :永久重定向,保持请求方法不变。

HTTP 重定向方式

HTTP 重定向主要利用了合法的重定向机制,通过各种方式将用户重定向到恶意网站。常见的方式包括:

  1. 开放重定向(Open Redirect)
  • 通过操控网站的 URL 参数,实现对重定向目标的控制。例如,合法网站的 URL 参数
    redirect=http://example.com
    被替换为
    redirect=http://malicious.com
    ,导致用户被重定向到恶意网站。
  1. 钓鱼(Phishing)
  • 利用重定向将用户引导到伪装成合法网站的恶意网站,诱骗用户输入敏感信息(如登录凭证、银行账号)。
  1. 恶意软件传播(Malware Distribution)
  • 通过重定向将用户引导到托管恶意软件的网站,诱骗用户下载和安装恶意软件。

low

随便点击一个链接,发现url栏有传参点

定位源码查看,发现重定向点

修改为
source/low.php?redirect=http://www.baidu.com

成功跳转

源码审计

没有存在过滤,不安全

<?php
// 检查URL中是否存在'redirect'参数,并且该参数不为空。
if (array_key_exists("redirect", $_GET) && $_GET['redirect'] != "") {
    // 如果存在'redirect'参数且不为空,则进行重定向到指定的路径。
    header("location: " . $_GET['redirect']);
    exit; // 终止脚本执行
}
// 如果'redirect'参数不存在或为空,则返回HTTP 500状态码并显示缺少重定向目标的错误信息。
http_response_code(500);
?>
<p>Missing redirect target.</p>
<?php
exit; // 终止脚本执行
?>

medium


low
级别的方法没什么区别,查看源码可以发现不同的地方在于禁用了
http://,https://
字段

构造url绕过
source/low.php?redirect=www.baidu.com
,如果没有明确指定协议,直接以
//
开头,则表示使用和当前页面相同的协议,便可以绕过了

源码审计

利用正则表达式检查是否含有
http:// https://
字段。如果有则过滤

<?php
// 检查URL中是否存在'redirect'参数,并且该参数不为空。
if (array_key_exists("redirect", $_GET) && $_GET['redirect'] != "") {
    // 使用正则表达式检查'redirect'参数是否包含不安全的绝对URL。
    if (preg_match("/http:\/\/|https:\/\//i", $_GET['redirect'])) {
        // 如果是绝对URL,则返回HTTP 500状态码,并显示错误信息。
        http_response_code(500);
        ?>
        <p>Absolute URLs not allowed.</p>
        <?php
        exit; // 终止脚本执行
    } else {
        // 如果是相对路径,则进行重定向到指定的路径。
        header("location: " . $_GET['redirect']);
        exit; // 终止脚本执行
    }
}
// 如果'redirect'参数不存在,则返回HTTP 500状态码并显示缺少重定向目标的错误信息。
http_response_code(500);
?>
<p>Missing redirect target.</p>
<?php
exit; // 终止脚本执行
?>

high

查看源码可以发现与上面两个级别不同的是检查是否有
info.php
字段,如果没有,则不能进行重定向

构造代码绕过:source/low.php?redirect=http://www.baidu.com?id=info.php

成功绕过

源码审计

检查了url种是否含有
info.php
字段,如果没有则会过滤

<?php
// 检查URL中是否存在'redirect'参数,并且该参数不为空。
if (array_key_exists("redirect", $_GET) && $_GET['redirect'] != "") {
    // 检查'redirect'参数中是否包含"info.php"。
    if (strpos($_GET['redirect'], "info.php") !== false) {
        // 如果包含"info.php",则进行重定向。
        header("location: " . $_GET['redirect']);
        exit; // 终止脚本执行
    } else {
        // 如果不包含"info.php",返回HTTP 500状态码和错误信息。
        http_response_code(500);
        ?>
        <p>You can only redirect to the info page.</p>
        <?php
        exit; // 终止脚本执行
    }
}
// 如果'redirect'参数不存在或为空,则返回HTTP 500状态码并显示缺少重定向目标的错误信息。
http_response_code(500);
?>
<p>Missing redirect target.</p>
<?php
exit; // 终止脚本执行
?>

impossible

源码审计

采用了更加符合现实情况的方法,较为安全

<?php
// 初始化目标URL为空字符串
$target = "";
// 检查URL中是否存在'redirect'参数,并且该参数是一个数字。
if (array_key_exists("redirect", $_GET) && is_numeric($_GET['redirect'])) {
    // 根据'redirect'参数的整数值选择不同的重定向目标。
    switch (intval($_GET['redirect'])) {
        case 1:
            // 如果参数值为1,设置目标为"info.php?id=1"
            $target = "info.php?id=1";
            break;
        case 2:
            // 如果参数值为2,设置目标为"info.php?id=2"
            $target = "info.php?id=2";
            break;
        case 99:
            // 如果参数值为99,设置目标为"https://digi.ninja"
            $target = "https://digi.ninja";
            break;
    }
    // 如果目标URL已被设置,执行重定向。
    if ($target != "") {
        header("location: " . $target);
        exit; // 结束脚本执行
    } else {
        ?>
        Unknown redirect target. <!-- 输出未知重定向目标的错误信息 -->
        <?php
        exit; // 结束脚本执行
    }
}
?>
Missing redirect target. <!-- 输出缺少重定向目标的信息 -->

本文以个人视野聊下软件架构师的工作以及软件架构设计知识。做开发工作接近10年了,期间主要做Windows应用开发。在成熟的“华南区最大WPF团队”希沃白板呆了较长一段时间、后面从0到1构建Windows技术栈以及会议屏软件集,在软件设计这块自己成长了很多。需要梳理了解的软件设计,自己阶段性总结一下

先讲软件架构师,职责是服务团队和项目产品,角色对应的工作范围:

  1. 跟进项目进展 - 根据优先级随时支撑需求开发延迟、客户严重问题

  2. 关注项目质量 - 审核开发提交代码、浏览仓库最近改动,代码是否有实现缺陷、设计扩展问题,确认需求开发、问题处理有无更优解决方案

  3. 解决疑难杂症 - 项目及产品肯定会遇到一些难复现、路径深的技术问题,业务功能会有性能瓶颈及框架类问题,这些都需要架构师参与定位解决、提供技术解决方案。不要让问题阻塞项目节点,或者沉淀为团队的技术债务,要尽快解决

  4. 持续技术创新 - 当前产品的性能、框架是否需要继续优化,产品可预见的发展方向上技术可行性是否都已验证

  5. 推进团队成长 - 组织技术栈培训技、把技术知识变成团队内的常识,带头分享技术实现、软件设计概念

架构师如何做好呢,我们下面以项目、团队、个人三个角度详细说下

架构师需要善于发现问题。架构师是全局性角色,需要关注的面很多,可以站在团队、项目、公司的角度多思考技术层面自己还能做啥,要给自己找事情做。发现问题比解决问题更重要,产品功能是否不是最优方案,小伙伴提交代码是否有缺陷,发现问题不一定要你自己去解决,可能其它人解决比你更适合。在会议屏软件全家桶开发过程中,软件从0到1开发的数量多,另外团队人员最多的时候12个,每个人都在疯狂开发需求、提交代码,有些功能没有想清楚实现或者设计就拍脑袋敲代码了,最后方案改了一版又一版。也有一些功能没办法尽善尽美,针对一些小使用场景、逻辑边界测试提了BUG,开发未能权衡取舍,最后代码改了又撤回。如果架构师去关注项目开发流程,以自己扎实的技能、敏锐的直觉去把一些技术风险找出来,减少测试成本、软件后续维护成本,降低低级严重向客户暴露的风险、提升用户体验。还有产品的启动性能、业务耦合问题等,要以全局性眼光多发现问题,先把问题抛出来

架构师需要学会打杂。功能迭代开发进度延迟、客户反馈线上版本紧急问题,人员不够或者时间原因,需要你主动发现并顶上去,架构师不只是写自己代码、更应该是一个能救场的。问题无论简单或者复杂,代码不论是UI还是框架,你都应该能Hold住,而不是按照自己偏好只搞架构、框架。首先架构师的工作肯定是饱满的,问题过来了你能合理评估优先级,能及时调整去协助。这中间需要你提升平时的工作效率,效率提升才能完成更多的任务。

架构师需要技术创新。当前项目那些遗留的技术债务要抽时间突破;之前急于开发但未确认的技术选型,要严格对比高性能、高可用确认最终的技术方案;产品后续发展方向或者3-5年可能会去考虑的功能,要去提前确认、研究技术可行性形成技术组件,提前设计相应的架构/框架去满足后续的业务复杂度。这些都应该是常态化的工作,定期思考、整理下,还有哪些技术后续需要考虑的。当然这技术创新,不只是功能开发,还可以是非功能比如代码的稳定性、业务操作的CPU性能/内存损耗、软件的后续扩展性等等,比如设计高性能、可扩展、可维护的通信组件也是一个NICE成果。

架构师需要考虑团队效率。如何提高团队开发速度,如何降低维护成本、保障产品稳定性,如何让团队开心的写代码?首先,就需要清真的代码逻辑、清晰合理的模块分层,除了个人开发的能力外,架构师可以多考虑组件化比如抽取日志、配置、通信等通用组件以及部分逻辑不变的业务组件,组件化设计概念可以看看
组件/框架设计原则 - 唐宋元明清2188 - 博客园
。组件化做好,代码复用提升了,模块解耦了,不只是软件开发维护效率提高,后续定位问题效率也大幅提升,因为不再需要看一个问题沉入到复杂的代码堆里去一行行确认,你怀疑通信问题那就把通信组件拿出来,建个demo试试也许能很快把问题复现确认。另外,站在团队的角度看看小伙伴们的工作,简单重复、没有技术含量的流程性操作,没准通过框架、小工具就可以减少,比如Nuget源代码替换
.NET 高效Nuget管理工具(开源) - 唐宋元明清2188 - 博客园

架构师需要带团队一起成长。组织技术栈培训,比如我们团队是Windows技术栈
Windows应用技术栈知识图谱 - 唐宋元明清2188 - 博客园
,.NET/WPF有哪些主要知识点、有哪些方法/工具定位问题,争取把这些技术知识变成团队内的常识,提升团队整体素养。带头分享技术实现案例、软件设计原则概念,培养团队内向上的技术氛围,他们在这个团队能学到很多东西、项目产品随着小伙伴们成长也能有更好的技术实现与用户体验,双赢了不是。

架构师需要持续提升自己能力。架构师基本上是团队内技术天花板了,所以你不能让自己停下来,持续学习、让自己保持一个“专家”才能更好的服务团队、做好大家的榜样。你持续的学习总结、思维创新让你在技术上持续领先,当然技术上架构师不可能都比小伙伴厉害。极客时间的“让学习养成习惯”相当有道理,工作前面几年学习是让自己更专业,工作很多年的学习是让自己更领域,不管是行业技术还是架构思维你都会有自己的想法、能把知识串起来并落地。

我们再聊下软件架构知识,这里只谈理论哈,像互联网公司的系统中台架构、Redis负载均衡高并发集群,就是具体的落地方案了。我最熟悉的Windows应用,相对大厂中台只是小卡拉米。。。先把自己学习、掌握的软件设计整理清楚就行了。我们都知道架构是解决业务及系统复杂度的解决方案,架构设计是关注软件长期需求变化的理论,如果后续没有需求或者已经实现的功能不再变化,那就不需要架构了、随便码代码就好。另外如果架构一开始设计复杂了,开发成本会提高、项目周期会拉长,软件发布版本可能就会遥遥远期,项目所以针对变化,如何恰到好处的完成当前软件业务开发、又能满足后续项目需求发展需要,就值得多琢磨琢磨。先列个大纲:

  • 软件解耦

    • 封装与抽象
    • 分离关注点
    • 中间层/模块化
  • 编程范式

    • 面向对象编程
    • 函数式编程
    • 结构化编程
  • 软件设计原则

    • Solid原则
    • Kiss原则
    • YAGNI原则
    • DRY原则
    • 设计模式
  • 架构设计原则

    • 简单
    • 合适
    • 演进
  • 设计衡量指标

    • 可维护性 - 可读性、简单设计、稳定性
    • 可用性 - 比如高性能、高安全
    • 可扩展性 - 为后续修改及扩展的变化,提供最小改动方法
    • 可测试性

软件解耦,指的是降低系统内不同类、不同模块之间的依赖,降低依赖关系能减少软件业务的理解成本、维护成本,改代码不再是牵一发动全身或者定位一个BUG不会沉到代码的海洋里。耦合首先需要解决业务混杂的问题,需要将业务拆分为各个独立的小模块,然后再将相关的小模块组合成较大模块、添加有限的对外暴露接口,也就是大家常说的高内聚低耦合。封装组件/模块时,可能会遇到通用代码调用业务代码的问题,这就需要分离关注点,也叫分离“变化”。我们都知道软件后续的修改或者扩展,本质上还是“变化”,所以我们可以“变与不变”拆开,高层使用抽象接口,低层变化的部分通过接口实现业务,后续扩展也可以通过多态实现新的特性。分离关注点,还有“高频与低频”即变化多的与变化少的,也要实现分层,变化多的挪到低层,变化少的放在相对高层。还有平行的逻辑关注点如读写分离,也是一样的道理,读写操作都比较复杂的话建议拆开。根据实际场景考虑下代码后续可能的变化,将变化隔离开来,隔离开之后就有了所谓的中间层和模块,模块化可以是文件夹,也可以是项目、组件,Nuget包就是某一类功能的技术组件。如果需要总结的话,那就是把变化的部分往上浮、不变的部分往下沉,分层合理软件就基本解耦了。

面向对象OOP,封装继承多态大家都有了解,刚工作时就记住了对吧,但我想说的是工作很多年后你再琢磨下可能有更多的理解。封装我理解并不是简单的组合,最重要的是先拆分,只有拆细了才能识别其中的变化,把变化放在封装之外;继承,尽量减少使用父子类的堆叠,而是使用抽象继承,使用抽象类或者接口固定不变的操作,其它的代码放在多个子类中分别去实现,如果是公共代码可以通过组合的方式添加新的类来调用;而多态呢,接口是有意义的,很多开发人员滥用了接口,接口定义一大堆结果没有实际用处,如果后续没有扩展就不要添加接口了。接口我理解只有俩种场景,多个类来实现一个接口这叫多态,一个类实现多个接口这叫接口隔离,其它的场景就不要滥用接口了。

编程范式,指的是结合不同编程思维来编写代码。面向对象OOP、函数式编程、结构化编程,这三种编程方式各有各的优势,我理解的是,先使用面向对象能帮你抽象化业务、梳理类与类之间的关系、搭建系统结构,即类以及类之间的设计让面向对象编程解决。然后再使用函数式编程定义接口API、方法,函数需要减少全局字段的使用,能减少函数与函数之间的干扰和耦合,输入同等参数情况下输出结果是一致的,即满足可测试性。当然函数式编程不只是定义函数,函数可以作为参数传递,也称作函数组合,相比对象组合函数颗粒化更小。最后在函数内使用结构化编程方式输出具体代码,结构化的代码有顺序结构、选择结构、循环结构,可读性相比面向对象好很多,相比跳来跳去的逻辑,结构化代码对开发者是最友好的。我的建议是自上向下、自外向内,依次使用面向对象方式、函数式编程理念、结构化编程工具来完成软件功能。

软件设计原则,是指导我们具体代码如何设计的一类理论,针对复杂的软件业务我理解本质还是如何识别变化、隔离变化、提前设计变化。最常见是Solid原则,单一职责SRP、开放封闭OCP、里氏替换LSP、接口隔离原则ISP、依赖倒置DIP。SRP告诉我们一个类或者模块只能做一类事情、要习惯去拆解业务,拆解的过程就是识别变化,拆解之后我们就能识别哪些是不变的、少变的、后续多变的,所以一个模块里面最好的是不变代码、然后是少变的代码,不变代码的比重是衡量模块内部设计好坏的标准。OCP大家都知道是扩展开放、修改封闭,这里讲的是把刚刚SRP识别出来的不变定义成高层代码,高层代码内部给外部的低层代码即后续变化留下扩展点比如抽象、接口,外部可以通过多态实现新的特性、接口注入后续可能变化的实体等,所以OCP告诉我们要提前考虑好后续变化的部分,后续业务变化尽可能只新增代码、不要去修改原有代码。LSP则是对变化的部分有一定约束,子类能替换父类出现的任何位置而不出现问题,那就需要子类要按照原有父类的规则去添加扩展而不是破坏或者新增其它特性。当然这里的子类父类不单是指继承关系,我们上面说了推荐使用抽象继承,所以里氏替换强调的是变化部分要沿用原来定义好的抽象设计规则。ISP告诉我们接口不是越大越好,如果接口类较复杂、实现类不需要接口类中这么多冗余接口,那就需要拆解接口类,让实现类按需要去实现接口。另外拆解接口也有一个好处,某个实现类可以实现多个接口,即实现类与接口类的关系可以是1对多、也可以多对1,针对变化部分后续扩展会更加灵活。DIP是针对高低层代码循环嵌套场景问题给的解决方案,上面OCP我们讲了高层代码我们想把不变的部分固定下来,但在高层代码内需要触发执行某些低层变化,新手很容易直接调用低层代码导致依赖混淆、流程嵌套,这时可以让高层对变化部分定义抽象接口、低层去实现此接口的具体逻辑、然后将低层实现通过接口注入高层代码来执行,原本的高层依赖低层情况就变成了低层依赖高层定义的接口,低层代码说你不要依赖我、我俩共同依赖抽象接口,即依赖倒置。所以Solid原则依次是针对变化,如何识别变化、扩展变化不要影响不变部分、变化要按规则实现、通过接口拆分隔离变化、通过接口注入扩展变化。

软件设计原则,比较知名的还有KISS原则、YNGNI原则、DRY原则,我们分别简单介绍下。KISS是Keep it simple and stupid缩写,代码越简单越好,设计对当前业务发展够用就行,保持简单能降低开发者学习成本、理解成本,为了体现自己的设计能力把系统设计搞复杂的话,那后续变化的部分实现会很难。YNGNI-You aren't gonna use it 指不会用到的设计就不要添加,应该考虑到当前产品业务以及后续几年内可预见的发展情况,对变化留下相应扩展点就行了,网上说的“如非必要,勿增实体”就是这个意思。DRY-Don't repeat yourself原则针对的是代码重复,很多时候我们经常重复造轮子,或者新增功能时把其它地方的代码复制一份修改一下,不要小看重复带来的问题,共性的代码如果不是统一放在一块维护,你可能改完一个问题其它位置又暴露出来同样问题。通过分离关注点,将共性代码拆分出来、做好封装、针对变化留下扩展点,你后续维护会容易很多,另外共性代码也有机会独立去深挖性能、替换更优的技术实现。另外说下设计模式,我其实不建议初级开发者看,多去琢磨设计原则就好,因为设计原则才是最基本最本质的准则,设计模式只能算是在特定场景下的解决方案,不一定适用你当前的编程语言、业务需求,熟悉理解设计原则概念后只要你能做好解耦、处理好变化,可能你的代码设计就是一类新的设计模式,不是只有那常用的23种设计模式。设计模式可以去看,了解设计模式背后的设计原则思想就行了,具体的模式代码可以忘记,至少我是这么想的。

架构设计原则,与上面介绍一堆讲模块分层以及模块内设计略有点不同,针对的是系统/框架的选型以及设计。架构一开始要尽量简单,简单优于复杂,前期简单的设计对团队刚起步、项目刚开展会非常友好,复杂的设计会让团队开发人员开发成本高、BUG多产品不稳、甚至是项目延期。合适也是类似道理,有些资深开发一开始就选用一个超级牛逼的架构,对标竞品的业界领先方案,完全复刻对手产品技术架构、甚至设计了一个比竞品架构更复杂的高性能高扩展架构,这种拍脑袋拍胸脯的设计很大概率会让项目一地鸡毛,因为你的团队没那么多人、没那么牛逼的同事、甚至烧钱金额也有限制,所以适合当前团队、项目才是最好的,你可以规划后续扩展、未来的风险但没必要落地实现。简单、合适说的都是当前设计方案需要满足的,那后续企业壮大了、用户活跃群体暴增了之后,就需要架构的演进了。一开始架构可以只设计一个雏形、满足当前业务,有精力的话可以为3-5年或者可预见的产品发展添加扩展设计,随时间推移你要拥抱变化、复盘当前架构问题以小的幅度调整架构,后面企业壮大也有更多的资金、更牛逼的伙伴来同你设计、甚至大改架构。大的系统架构,可以使用DDD来划分多个层级应用层、控制层、领域层之间的业务,当然一般的中小应用或者初中级开发就不要去硬套领域驱动设计了,对你的团队不是好事、也没必要,大部分的应用能够把模块解耦做好就算还可以了。

软件/架构设计的好坏,上面的理论指导并不能作为衡量标准、只能说是给我们一个设计方向,评估设计标准有可维护性、可用性、可扩展性、可测试性。
可维护性包括代码易读、简单设计、代码稳定等
,一个架构它的可读性很重要,首先是分层合理、模块耦合少且逻辑清晰,然后架构能支撑复杂的业务而且设计还简单明了的话也是我们需要的,其它的比如接口对外暴露是否最少,存在冗余接口函数或者参数会对使用者造成不必要的困扰,设计对外要简洁、单一。以技术组件为例,组件都是给自己以及其它开发人员使用的,给各个应用调用的,那我们就需要考虑使用成本以及学习成本。另外,软件设计需要稳定,大家都知道重构,有些开发人员经常大改流程和架构,这也是有些不了解软件设计的测试或者主管在听到“重构”俩字的时候经常会表示抗拒,他们只会看你版本的质量和项目开发时间,经常大改肯定不行啊,开发人员功能开发时除了考虑业务需求还要自己多考虑些非功能性需求比如小步的重构、优化之前的设计来满足当前业务以及后续扩展,这些都考虑到了软件都会容易维护。
可用性是指要满足业务场景使用,相关的有高性能、高安全
,像互联网大厂他们那些服务集群架构满足高性能、高安全,业务才能正常开展,否则时不时来个宕机对公司影响是很大的,所以架构方案要能满足业务场景正常使用。
可扩展性讲的是软件有没有为后续业务添加足够的扩展点
,当前架构是否能够支持后续业务的扩展,不要需求过来了你再说架构需要重新调整,这是很严重的问题,扩展性是需要提前去考虑的,上面说的很多设计理论都是针对变化的准则,后续有哪些变化、如何更好的实现变化,不需要你现在去实现那些需求,而是留下扩展点。当然架构设计是要掌握一个度,过度设计也很不好,搞复杂、冗余的架构设计你团队小伙伴肯定会吐槽。
可测试性是指软件内部小颗粒的模块或者类是否支持测试
,如果可测试性好那我们模块集成时就更加容易,定位复杂问题时能快速的验证各独立模块是否正常。提高可测试性,可以考虑减少全局字段的使用、减少代码耦合、保障清晰合理的代码依赖,用Kiss原则让设计保持简单,少使用单例模式、模块能够快速初始化,使用函数式编程让模块有一致性的输入输出结果等等。

总之,架构设计的目的是解决软件业务需求以及非功能性需求比如高性能高并发等带来的系统复杂度,需要掌握个度,不能可用性不足、设计不到位,也不能过度设计。另外架构设计是需要提前考虑、未雨绸缪的,业务需求开发有项目进度要求,架构设计呢也要在早期给自己压力、迟早完成团队内的框架搭建、提前布局需求变化,随时准备好应对大量需求暴增的场景。软件以及架构设计不同于具体的技术实现,理论的东西需要自己多琢磨,形成你的习惯性判断,同一个设计原则每个人的理解也不一样。需要注意的是,理论只是理论,一定要多实践,不能口上讲的头头是道、代码写的稀里糊涂。

以上这些是我个人经验总结,肯定不全面也不细致建议大家多阅读架构书籍(这里我列了一些书籍,大家可以找里面架构相关的购买
.NET开发一些书箱推荐 - 唐宋元明清2188 - 博客园
)、自己多琢磨整理整理,我的知识图谱不一定适合你、尤其是这类需要理解体会的软件设计思想。我自己还有很多需要学习成长的地方,这也是我阶段总结、分享的原因,大家有觉得不合理的及时提出来、互相学习学习。零零散散整理花了1天多时间,写的略微有点乱。。。望大家多包容。