Resolve HTTP Multipart/related request on Spring6

系统从Spring5升级到Spring6, 除了要进行把javax的api迁移到Jakarta、升级Servlet容器到支持你所选的Jakarta的版本、升级Spring Security对应的API等这些常规操作,还可能遇到一些trick的问题。这里聊一下因为老系统没有限制客户端发送 multipart/related 这种请求而在升级后造成的问题及解决,虽然感觉这种场景 99.999% 的项目都不会遇到。
以流水帐的方式过一下。
升级前Spring5+Jetty9, 升级后Spring6+Jetty11

1)系统上线几天后,有客户说upload csv文件不成功。

2)最后从SumoLogic日志中发现原因是ContentType格式不对,又进一步确认是Spring5的系统支持 multipart/related 而Spring6 不支持造成的。

1
2
Caused by: jakarta.servlet.ServletException: Unsupported Content-Type [Multipart/Related; boundary=AAABBB; type="text/xml"; start="root-part--123"], expected [multipart/form-data]
at org.eclipse.jetty.server.Request.getParts(Request.java:2324) ~[jetty-server-11.0.19.jar!/:11.0.19]

3)Spring5 是使用Spring自带的MultipartParser,在解析后传给 servlet controller。系统使用Jetty9作为servlet容器。

4)Spring6 之后之前的 CommonsMultipartResolver 被替换为 StandardServletMultipartResolver。而StandardServletMultipartResolver会依赖容器来对Multipart请求做解析。(容器的实现必然有差别)

1
Several outdated Servlet-based integrations have been dropped: e.g. Apache Commons FileUpload (org.springframework.web.multipart.commons.CommonsMultipartResolver), and Apache Tiles as well as FreeMarker JSP support in the corresponding org.springframework.web.servlet.view subpackages. We recommend org.springframework.web.multipart.support.StandardServletMultipartResolver 

5)其实不管Jetty 9还是Jetty 11其实都是不支持multipart/related的,之前没有问题是因为CommonsMultipartResolver支持。这样controller直接收到Multipart file这个数据。

6)搭建环境重现、Debug问题。

这里推荐IntelliJ插件jump-to-line

还有个调试技巧是利用IntelliJ的 条件断点及 Evaluate and Log 进行一些变量值的动态修改。

7)修改 org.eclipse.jetty.server.Request中 如下代码

1
2
3
4
5
6
7
8
9
10
public Collection<Part> getParts()

// if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.valueParameters(contentType, null)))
if (contentType == null)


private MultiParts newMultiParts(MultipartConfigElement config, int maxParts) throws IOException
{
// MultiPartFormDataCompliance compliance = getHttpChannel().getHttpConfiguration().getMultipartFormDataCompliance();
MultiPartFormDataCompliance compliance = MultiPartFormDataCompliance.LEGACY;

修改 org.eclipse.jetty.server.MultiPartInputStreamParser 中如下代码

1
2
3
4
protected void parse()
// if (_contentType == null || !_contentType.startsWith("multipart/form-data"))
if (_contentType == null)
return;

绕过条件限制后,发现对普通csv文件通过http Multipart/related上传是可以处理了,controller 可以接收到 MultipartFile 类型的 file 参数了。
其实这个蛮侥幸的,如果Jetty代码压根不支持,估计就得再用其它办法了。

8)后来发现zip格式不支持。Debug后发现是Jetty自己在内部处理时,必须要求临时文件的目录要存在,所以有加了对应逻辑。如下:

1
2
3
4
5
6
7
public void write(String fileName) throws IOException
{
if (_file == null)
{
_temporary = false;
// Make sure the file/directory _tmpDir.getAbsolutePath() + fileName existed.
touchTmpFileForJetty(_tmpDir.getAbsolutePath(), fileName);

这样修改之后通过了QA的测试。

9)为了测试 multipart/related 请求,也颇费周折。

通过curl命令实现了发送 multipart/related 请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
boundary="upload_boundary"

body=$(cat <<EOF
--$boundary
Content-Disposition: form-data; name="file"; filename="myuploaded.csv"
Content-Type: text/xml; charset=UTF-8
Content-Transfer-Encoding: binary

$(cat /my-path-to-file/my.csv)
--$boundary--
EOF
)

curl -v -X 'POST' \
-H 'accept: application/json' \
-H 'Authorization: YOUR-BASE64-USERIDPWD' \
-H "Content-Type: multipart/related; boundary=$boundary" \
-d "$body" \
'https://Your-Server:Port/service-path'
  1. 通过这 curl 命令向spring6 发送没问题。但是向 spring5系统发送后却得到500响应。但是通过java程序发送的multipart/releated请求确没问题。???

后台错误日志:

Caused by: org.apache.commons.fileupload.MultipartStream$MalformedStreamException: Stream ended unexpectedly

11) 为了搞清原因,在本地把 Mitmproxy 跑起来抓包。
从界面上怎么也看不出root cause,直到把请求通过 mitmproxy 导出成curl命令,才发现是换行表示的不同造成的。
通过Java程序发送的能被Spring5处理的请求是\r\n作为换行。

-d '--upload_boundary\x0d\x0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"\x0d\x0aContent-Type: text;

而curl发送的就是\n。

-d '--upload_boundary\x0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"\x0aContent-Type: text/xml;

12)为了证实确实是换行符造成的, 把/n转为 /r/n后通过curl命令发送后 Spring5也能处理了。

这个规范 rf7230 上也说有的请求接受者做得更“健壮”可以接受LF结尾的请求。
实际上也就是这些“健壮”破坏了规范。(另外,如果从window系统上用curl命令,应该默认就是CRLF的吧?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
echo $body > body.txt
cat -e body.txt

unix2dos body.txt
cat -e body.txt

RNBody=$(cat body.txt)
echo $RNBody | cat -e

curl -v -X 'POST' \
-H 'accept: application/json' \
-H 'Authorization: YOUR-BASE64-USERIDPWD' \
-H "Content-Type: multipart/related; boundary=$boundary" \
-d "$RNBody" \
'https://Your-Server:Port/service-path'

下面代码演示如何发送zip这样的二进制格式文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
boundary="upload_boundary"

# 这里使用系统默认回撤换行。
{
echo "--$boundary"
echo "Content-Disposition: form-data; name=\"file\"; filename=\"example.zip\""
echo "Content-Type: text; charset=UTF-8"
echo "Content-Transfer-Encoding: binary"
echo ""
cat /your/path/to/zipfile
echo ""
echo "--$boundary--"
} > zip_body.txt


curl -X POST --proxy http://127.0.0.1:7070 -k \
-H "Content-Type: multipart/related; boundary=$boundary" \
-H "Authorization: Basic XXXXXX" \
--data-binary @zip_body.txt \
https://Your-Server:Port/service-path


# 这里明确使用 \r\n
{
echo -ne "--$boundary\r\n"
echo -ne "Content-Disposition: form-data; name=\"file\"; filename=\"example.zip\"\r\n"
echo -ne "Content-Type: text; charset=UTF-8\r\n"
echo -ne "Content-Transfer-Encoding: binary\r\n"
echo -ne "\r\n"
cat /your/path/to/zipfile
echo -ne "\r\n"
echo -ne "--$boundary--\r\n"
} > zip_body_inCRCL.txt

curl -X POST --proxy http://127.0.0.1:7070 -k \
-H "Content-Type: multipart/related; boundary=$boundary" \
-H "Authorization: Basic XXXXXX" \
--data-binary @zip_body_inCRCL.txt \
https://Your-Server:Port/service-path

13)中间也尝试通过filter在中间使用 Commons FileUpload 2
但是遇到 Stream ended unexpectedly 的问题。另外,在Tomcat做容器的POC中,也是遇到类似的问题。当时debug时发现似乎是跟回车换行有关。当时也都是通过curl命令验证的。但因为自定义Jetty的方案已经可以work,所以就没再继续看。现在回头看很当时遇到的问题很可能跟Spring5遇到的一样。也许发送前对回撤换行处理一下,或许也可以解决。

14)如果通过 Commons FileUpload 2 + Filer 的方式可以解决,那这个方案就是最好的。最不好的方法其实就是这种定制Jetty代码,对以后的升级维护都是潜在的极大风险。

15)想起那句话,重要的是系统要限制能做什么。 为了这个patch前后花费的人天挺多的。。。新版本还delay了好久。


References:

创建 AWS EFS

本文基本是基于此efs workshop的记录和扩展。

要创建一个 EFS 资源,大致有以下几个步骤:
要在哪个 VPC 上创建 –> 这个 VPC 上子网的 CIDR
创建一个 SG –> 设置这个 SG 的 ingress rule: 对子网开放 NFS 的 2049
创建 EFS,根据需求设置不同的参数比如是否加密、备份、performance mode、throughput-mode 等。
找到 VPC 上的 public subnet,在这些 public subnet 上创建 Moint Target。

有了 mount targets,这个 NFS 就已经可以对外提供服务了。
如果需要对 mount 的网络文件系统的目录设置特定的 user、group 属性,那么可以通过在这个 NFS 上创建 Access Points 完成。

因为 EFS 是可以跨 region 在这个 region 的所有 AZ 中可用的一个 NFS,所以需要 VPC ID 应该是比较容易理解的。

下面介绍一下如何通过 aws cli 创建 EFS 及其 Access Points,完整的脚本可以在这里下载 create_efs.sh, create_access_points.sh

设置参数

这些变量定义了我们当前的 aws 环境以及要创建的资源名称等信息。

首先我们可以设置一些变量定义当前环境

AWS_PROFILE=myProfile
AWS_REGION=us-west-2
CLUSTER_NAME=myCluster

设置中间过程中会用到的常量

MOUNT_TARGET_GROUP_NAME=mySG4EFS
MOUNT_TARGET_GROUP_DESC="NFS access to EFS from EKS worker nodes"
EFS_NAME=myEfsName

1)获取 VPC ID

因为这里创建出来的 EFS 要供 EKS 的 pod 使用,所以 VPC 的获取是根据 eks cluster 得到的。

VPC_ID=$(aws eks describe-cluster --profile $AWS_PROFILE --region $AWS_REGION --name $CLUSTER_NAME \
        --query "cluster.resourcesVpcConfig.vpcId" --output text)
echo "The $CLUSTER_NAME includes the VPC $VPC_ID"

2)获取 VPC 下的 CIDR

CIDR_BLOCK=$(aws ec2 describe-vpcs --profile $AWS_PROFILE --region $AWS_REGION \
            --vpc-ids $VPC_ID --query "Vpcs[].CidrBlock" --output text)
echo "The CIDR blocks in the $VPC_ID : $CIDR_BLOCK"

3)在 VPC 上创建 Security Group

MOUNT_TARGET_GROUP_ID=$(aws ec2 create-security-group --profile $AWS_PROFILE --region $AWS_REGION \
                    --group-name $MOUNT_TARGET_GROUP_NAME \
                    --description "$MOUNT_TARGET_GROUP_DESC" \
                    --vpc-id $VPC_ID \
                    | jq --raw-output '.GroupId')

4)设置去安全组的 ingres 对 2049 端口开放

aws ec2 authorize-security-group-ingress --profile $AWS_PROFILE --region $AWS_REGION \
  --group-id $MOUNT_TARGET_GROUP_ID --protocol tcp --port 2049 --cidr $CIDR_BLOCK

5)创建 EFS

aws efs create-file-system 命令本身并没有选项用于设置资源名称,而是通过 Tag key=Name 首先的。这里要注意 Name 单词的大小写,使用小写的 name 并不能设置 efs name。
通过使用 creation-token 来做到创建操作的等幂性。如果你的系统希望 efs 资源的 name 是唯一的,那么的选择使用 efs 的名称作为 creation-token 是个不错的选择。

FILE_SYSTEM_ID=$(aws efs create-file-system --profile $AWS_PROFILE --region $AWS_REGION \
  --performance-mode generalPurpose --throughput-mode bursting \
  --tags Key=Name,Value=$EFS_NAME \
  --backup --encrypted --creation-token "$EFS_NAME"_0 | jq --raw-output '.FileSystemId')
echo "The EFS $FILE_SYSTEM_ID is created."

查看某个 efs:

aws efs describe-file-systems --file-system-id $FILE_SYSTEM_ID

EFS 资源已经创建出来了,要让它能被使用就需要把它 mount 到 VPC 的 public subnets 上。
一个 subnet 是 public 的还是 private 的,并不是通过 subnet 对象的某个属性标识的,而是要看路由表里这个 subnet 有没有通向 0.0.0.0 的 internet gateway。下面的几个步骤就用于找到 public subnet 并把 EFS mount 到这些 public subnets。

6) 得到 eks 里的 subnetIds

eksSubnetIds=($(aws eks describe-cluster --profile $AWS_PROFILE --region $AWS_REGION \
                --name $CLUSTER_NAME --query "cluster.resourcesVpcConfig.subnetIds" \
                --output text))
echo "The eks cluster $CLUSTER_NAME VPC $VPC_ID includes the subnets: $eksSubnetIds"

7) 找到 internet gateway

IGW_ID=$(aws ec2 describe-internet-gateways  --profile $AWS_PROFILE --region $AWS_REGION \
        --filters Name=attachment.vpc-id,Values=${VPC_ID} \
        --query "InternetGateways[].InternetGatewayId" \
        | jq -r '.[0]')
echo "The internet gateway in the VPC $VPC_ID is $IGW_ID"
if [ "null" = "$IGW_ID" ] ; then
  echo "Can't find public IGW in VPN, exit ..."
fi

8) 找到 public subnets

for subnetId in ${eksSubnetIds[@]}
  do
      echo "Check the subnet " $subnetId
      IGW_IN_ROUTS=$(aws ec2 describe-route-tables --profile $AWS_PROFILE --region $AWS_REGION  \
                    --filter Name=association.subnet-id,Values=$subnetId \
                    --query "RouteTables[].Routes[]" \
                    | jq -r '.[] | select(.DestinationCidrBlock=="0.0.0.0/0") | .GatewayId')
      if [ -z $IGW_IN_ROUTS -o "null" = $IGW_IN_ROUTS ] ;  then
        echo "The subnet $subnetId is a private subnet."
      else
        echo "The subnet $subnetId is a public subnet. $IGW_ID $IGW_IN_ROUTS"
        if [ "$IGW_ID" = "$IGW_IN_ROUTS" ] ; then
          echo "Creating the mount target in the subnet $subnetId."
          aws efs create-mount-target --profile $AWS_PROFILE --region $AWS_REGION \
                                      --file-system-id $FILE_SYSTEM_ID \
                                      --subnet-id $subnetId \
                                      --security-groups $MOUNT_TARGET_GROUP_ID
        elif [ "null" != "$IGW_IN_ROUTS" ] ; then
            echo "WARNING: The IGW id in routes does not equal with the one in VPC!"
        fi
      fi
  done

10) 创建 Access Point

到这里这个 NFS 已经可以在这个 VPC 里提供服务了。如果你的目录需要更精细的用户、组的设置,可以通过下面的命令创建 Access Point 来做更精细的控制。

ACCESS_POING_NAME=myAP
FILE_SYSTEM_ID=fs-082697b352a3230d1
AP_USER='{"Uid": 123, "Gid": 123, "SecondaryGids": [20]}'
AP_ROOT_DIR='/myapp/logs,CreationInfo={OwnerUid=123,OwnerGid=123,Permissions=0755}'

aws efs create-access-point --profile $AWS_PROFILE --region $AWS_REGION  \
--tags Key=name,Value=$ACCESS_POING_NAME \
--client-token "$ACCESS_POING_NAME" \
--file-system-id $FILE_SYSTEM_ID \
--posix-user $AP_USER \
--root-directory Path=$AP_ROOT_DIR

下面显示的是在 eks 中通过 StorageClass 自动分配 EFS 资源的场景下,如何如何设置相关属性。 参考 https://github.com/kubernetes-sigs/aws-efs-csi-driver 查看完整的 parameter 列表。

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: efs-sc
provisioner: efs.csi.aws.com
mountOptions:
  - tls
  - iam
parameters:
  provisioningMode: efs-ap
  fileSystemId: fs-012345678901010
  directoryPerms: "700"
  gidRangeStart: "1000"
  gidRangeEnd: "2000"
  basePath: "/dynamic_provisioning"


Reference:
[1]: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/file-storage.html
[2]: https://docs.aws.amazon.com/efs/latest/ug/creating-using.html
[3]: https://docs.aws.amazon.com/cli/latest/reference/efs/create-file-system.html
[4]: https://docs.aws.amazon.com/cli/latest/reference/efs/create-access-point.html
[5]: https://docs.aws.amazon.com/efs/latest/ug/creating-using-create-fs.html#creating-using-fs-part1-cli
[6]: https://stackoverflow.com/questions/48830793/aws-vpc-identify-private-and-public-subnet
[7]: https://www.baeldung.com/linux/jq-command-json
[8]: https://aws.amazon.com/premiumsupport/knowledge-center/eks-troubleshoot-efs-volume-mount-issues/
[9]: https://github.com/kubernetes-sigs/aws-efs-csi-driver

使用 AWS EFS 作为EKS里PV的后端存储 (1)

如何在 EKS 手工创建 AWS EFS 资源

预备知识

AWS 的 EC2 存储,有四种选择:

  • Amazon Elastic Block Store
  • Amazon EC2 instance store
  • Use Amazon EFS with Amazon EC2
  • Use Amazon S3 with Amazon EC2

下面的这张概念图很好的描述各种存储的位置层次。
EC2 Storage Options
在 EKS 的 node 如果是基于 EC2 的,那么 PV 就可以利用以上除去 S3 之外的其余三种作为底层存储。
参考:https://docs.aws.amazon.com/eks/latest/userguide/storage.html

关于 EC2 可以使用的存储的特性、使用场景,推荐阅读官方文档:https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Storage.html。

使用 aws cli 创建一个 EFS 资源

可以在 AWS Console 中创建 EFS https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AmazonEFS.html。
也可以通过使用 aws cli 在命令行创建,https://www.eksworkshop.com/beginner/190_efs/launching-efs/。
下面是根据上文得到的,通过 aws cli 创建 EFS 的脚本。
假设,你的 aws profile 是 myAwsProfile、eks 所在 region 是 us-west-2、eks 集群名称是 myCluster,而要创建的 EFS 名称是 my-test-efs
为了使用 EFS 需要创建 SecurityGroup 来允许对 NFS 端口 2049 的使用,这里设置 SG 名称为 SG_efs_demo。

设置环境变量:


# Set the input env vars
export AWS_PROFILE=myAwsProfile
export AWS_REGION=us-west-2
export CLUSTER_NAME=myCluster

# Set the output env vars
export MOUNT_TARGET_GROUP_NAME=perf-test-efs-group
export MOUNT_TARGET_GROUP_DESC="NFS access to EFS from EKS worker nodes"
export EFS_NAME=my-test-efs

获取 VPC ID


# Get eks cluster's VPC ID.
export VPC_ID=$(aws eks describe-cluster --profile $AWS_PROFILE --region $AWS_REGION --name $CLUSTER_NAME --query "cluster.resourcesVpcConfig.vpcId" --output text)
echo $VPC_ID

获取 VPC 里的 subnets


# Get the subnets's CIDR in the VPC.
export CIDR_BLOCK=$(aws ec2 describe-vpcs --profile $AWS_PROFILE --region $AWS_REGION --vpc-ids $VPC_ID --query "Vpcs[].CidrBlock" --output text)
echo $CIDR_BLOCK

创建 Security Group


# Create SG(allow port 2049 in ingress for all of the CIDR in VPC) for EFS
export MOUNT_TARGET_GROUP_ID=$(aws ec2 create-security-group --profile $AWS_PROFILE --region $AWS_REGION --group-name $MOUNT_TARGET_GROUP_NAME --description "$MOUNT_TARGET_GROUP_DESC" --vpc-id $VPC_ID | jq --raw-output '.GroupId')
aws ec2 authorize-security-group-ingress --profile $AWS_PROFILE --region $AWS_REGION --group-id $MOUNT_TARGET_GROUP_ID --protocol tcp --port 2049 --cidr $CIDR_BLOCK

# Get back the security-group informaation.
aws ec2 describe-security-groups --filters Name=group-name,Values=$MOUNT_TARGET_GROUP_NAME

创建 EFS


# Create EFS. https://docs.aws.amazon.com/cli/latest/reference/efs/create-file-system.html
# https://docs.aws.amazon.com/efs/latest/ug/creating-using-create-fs.html#creating-using-fs-part1-cli
# If no creation-token is provied, you'd better go to the AWS EFS Console
# to make sure the EFS is created or not to avoid too many EFS reources are created.
# 使用efs name作为前缀加序号的方式做 creation-token 是个不错的选择,
export FILE_SYSTEM_ID=$(aws efs create-file-system --profile $AWS_PROFILE --region $AWS_REGION \
  --performance-mode generalPurpose --throughput-mode bursting --tags Key=name,Value=$EFS_NAME \
  --backup --encrypted --creation-token "$EFS_NAME"_0 | jq --raw-output '.FileSystemId')

找到 VPC 中的 pubilc subnets


# Find out the public subtnets from the subnets of the eks cluster.

# 得到eks VPC的所有 subnetIds
# export eksSubnetIDs=($(aws eks describe-cluster --profile $AWS_PROFILE --region $AWS_REGION --name $CLUSTER_NAME --query "cluster.resourcesVpcConfig.subnetIds" --output text))

# 找到 Internet GW
export IGW_ID=`aws ec2 describe-internet-gateways \
  --filters Name=attachment.vpc-id,Values=${VPC_ID} \
  --query "InternetGateways[].InternetGatewayId" \
  | jq -r '.[0]'`

# 找到 public subnets: https://stackoverflow.com/questions/48830793/aws-vpc-identify-private-and-public-subnet
export PUBLIC_SUBNETS=`aws ec2 describe-route-tables \
  --query  'RouteTables[*].Associations[].SubnetId' \
  --filters "Name=vpc-id,Values=${VPC_ID}" \
    "Name=route.gateway-id,Values=${IGW_ID}" \
  | jq . -c`

把 EFS mount 到 public subnets,这样 EKS 里的 worker nodes 就都可以访问这些 EFS 了。


for subnet in ${PUBLIC_SUBNETS[@]}
do
    echo "creating mount target in " $subnet
    aws efs create-mount-target --profile $AWS_PROFILE --region us-west-2 --file-system-id $FILE_SYSTEM_ID --subnet-id $subnet --security-groups $MOUNT_TARGET_GROUP_ID
done

创建 Access Points

有了 EFS 之后就可以创建 AccessPoints 供应用程序使用了。关于更详细的 Access Points 介绍:https://docs.aws.amazon.com/efs/latest/ug/efs-access-points.html

ACCESS_POING_NAME=ap_db2
FILE_SYSTEM_ID=fs-055b5f1fcc7df3e4b
AP_DIR_USER='{"Uid": 70, "Gid": 70, "SecondaryGids": [70]}'
AP_ROOT_DIR='/mydataPath,CreationInfo={OwnerUid=70,OwnerGid=70,Permissions=0755}'

aws efs create-access-point --profile $AWS_PROFILE --region $AWS_REGION  \
--tags Key=name,Value=$ACCESS_POING_NAME \
--client-token "$ACCESS_POING_NAME"_2 \
--file-system-id $FILE_SYSTEM_ID \
--posix-user $AP_DIR_USER \
--root-directory Path=$AP_ROOT_DIR

注意:

1)Path 的格式必须满足下面的正则
^(\/|(\/(?!\.)+[^$#<>;`|&?{}^*/\n]+){1,4})$

所以 path 不能以 / 结尾。

2)创建的 access point 可以同名、可以同名同 path,唯一标识 access point 的是 Access point ID
3) 根据 id 删除一个 access point

aws efs delete-access-point –profile $AWS_PROFILE –region $AWS_REGION –access-point-id fsap-0a8b1b7d9e0c1c9c3

利用CoreDNS设置mongo支持SRV连接

MongoDB 从 3.6 开始,就支持 mongo+srv “DNS Seed List Connection Format”这种格式的连接串。
对客户端来说它可以隐藏后端 mongo 服务节点的变化,其显而易见的好处就是后端 mongo 节点的变化无需修改客户端连接参数,也就无需进行应用的重新部署。其工作原理主要是利用 DNS 对 SRV/TXT 记录的支持。因此为了使用 mongo+srv,我们需要一个 DNS 服务器并在其中设置指向 mongo 后端节点的 SRV 记录。
这里以 docker 的方式运行 CoreDNS 来作为本地 DNS 服务器使用本地环境演示一下如何配置以支持 mongo srv。

MongoDB

MongDB 这部分无需特殊配置,只要把 MongoDB 在本地以复制集(名为 rs0)安装并运行起来就好。

运行 CoreDNS

运行 CoreDNS 容器,并把本地~/dockerSrvStorage/dockerEnv/coredns 映射为 coredns 容器里的/root 目录。
配置 CoreDNS 使用容器里的/root/Corefile(对应于本地的~/dockerSrvStorage/dockerEnv/coredns/Corefile)作为配置文件。

docker run -d –name coredns –restart=always
–volume=~/dockerSrvStorage/dockerEnv/coredns/:/root/
-p 53:53/udp coredns/coredns
-conf /root/Corefile

配制 CoreDNS

Corefile 内容如下。其中定义了一个 dns zone ‘local.env’,对应的 DNS 记录文件使用/root/local.env。因为上一步已经把本地的~/dockerSrvStorage/dockerEnv/coredns 映射为了容器里的/roo 目录,所以在本地的这个目录创建 local.env 这个文件就可以了。

Corefile:

.:53 {
    forward . 8.8.8.8 114.114.114.114
    log
    errors
}

local.env:53 {
    file /root/local.env
    log
    errors
}

local.env
下面的配置表示本地机器域名设置为 dev.local.env,机器名 mongo.local.env 是 dev.local.env 这个机器的别名。
假设本地运行了一个数据库 DB1 以两 replicaset(名字是 rs0)方式运行的 mongo 实例,分别侦听在端口 27017,27117 上。下面的配置就可以提供一个 mongosrv 连接。
mongo+srv://DB1.mongo.local.env。

@    3600 IN    SOA dns.local.env. dhyuan.google.com. (
        2022030456 ; serial
        7200       ; refresh (2 hours)
        3600       ; retry (1 hour)
        1209600    ; expire (2 weeks)
        3600       ; minimum (1 hour)
        )

dev.local.env.    IN  A   127.0.0.1
mongo.local.env.  IN  CNAME   dev.local.env.
redis.local.env.  IN  CNAME   dev.local.env.
zk.local.env.     IN  CNAME   dev.local.env.

_mongodb._TCP.DB1.mongo.local.env. 86400 IN SRV   0        0      27017 mongo.local.env.
_mongodb._TCP.DB1.mongo.local.env. 86400 IN SRV   0        0      27117 mongo.local.env.
DB1.mongo.local.env. 86400 IN TXT   "replicaSet=rs0&authSource=DB1"

更改本地/etc/resolv.conf

把’nameserver 127.0.0.1’ 加在文件末尾,让本地的 CoreDns 参与解析。

验证

dig -t srv DB1.mongo.local.env

也可以通过文章https://www.mongodb.com/blog/post/mongodb-3-6-here-to-srv-you-with-easier-replica-set-connections 中 Reading SRV and TXT Records 部分提供的 python 解析 srv 的代码验证。

pip3 install srvlookup
pip3 install dnspython
python3 mongodb_srv_records.py DB1.mongo.local.env

Reference:
https://dev.to/robbmanes/running-coredns-as-a-dns-server-in-a-container-1d0
https://datatracker.ietf.org/doc/html/rfc2052
https://docs.mongodb.com/manual/reference/connection-string/
https://www.mongodb.com/blog/post/mongodb-3-6-here-to-srv-you-with-easier-replica-set-connections
https://coredns.io/manual/toc/

把某个git子目录迁移到新repo的根目录

对项目重构时有这样一个需求,1)要把代码库某个目录下的所有代码作为一个新代码库的根目录,2)并且之前所有的代码提交记录要一并迁移到这个新的 git repo。

当你尝试用 git filter-branch --subdirectory-filter YOUR_SUB_DIR -- --all 来解决问题时,会看到一个警告推荐我们使用 git filter-repo。它是一个用于重写 git history 的多功能小工具,用法参考filter-repo 使用手册。我们的需求在这里只是它的一个小 case。

下面直接列出操作步骤:

1)安装 git-filter-repo

brew install git-filter-repo

2)Clone 原来的 Repo

mkdir codebase
cd codebase
git clone YOUR_GIT_REPO_URL/myProject
cd myProject

3) 拉取所有信息到本地

git fetch --all
git pull --all

4)执行 filter-repo 命令,让某个子目录成为新 repo 的根目录。

git filter-repo --subdirectory-filter The_SubDir_in_myProject

5) 在 github/gitlab 创建一个新 repo,把这个 repo 设为这个子目录的 remote 目标

git remote add origin YOUR_NEW_REPO_GIT_URL

6) 把 master 的 history push 到新 repo

git branch -M master
git push -uf origin master

7)把所有 branchs/tags 都 push 上去

git push --all origin



Reference:
[1]: https://github.com/newren/git-filter-repo/

Authorization in Spring Security(1)

1. 根据什么数据进行授权

认证成功后以 GrantedAuthority 的形式保存在 Authentication 对象中的 authorities 会别用来进行授权运算。

请求被 AuthenticationManager 认证之后,其 Principal 的 authorities 以一组 GrantedAuthority 的形式被保存在 Authentication 对象里。

public interface GrantedAuthority extends Serializable {
    String getAuthority();
}

在是否可以对 security object (比如一个方法的调用、一个 web request 的处理)进行访问之前,需要使用 AuthorizationManager 来决定是否可以 invoke 这些 security objects。
如果这些授权不能以 String 的形式表达出来,那么就用返回 null 的形式告诉 AuthorizationManager/AccessDecisionManager 需要自己进行特殊的处理。 Spring Security 源码里就有一个这样的例子,请参阅: WebExpressionConfigAttribute, WebExpressionVoter。

2. 通过 AuthorizationManager 体会设计的改进

AuthorizationManager 是在 spring security 5.5 中被加入。从 spring-security-core-6.0 开始,AccessDecisionManager AccessDecisionVoter 已经被 deprecated,由 AuthorizationManager 取代其作用。 对于之前定制化 AccessDecisionManager AccessDecisionVoter 的代码应该考虑迁移到 AuthorizationManager。

AuthorizationManager
AuthorizationManager

AccessDecisionManager
AccessDecisionManager

AccessDecisionVoter
AccessDecisionVoter

  1. 之前 AccessDecisionManager 通过抛出异常,现在 default 的 verify 也是通过 exception。
  2. 明确返回 AuthorizationDecision 来标识。
  3. 之前有 support 方法,跟 AuthenticationProvider 的思路很像。
    从方法签名可以直接看出上面这几点。官方文档列出了更有意义的变化:
  4. AuthorizationManager 的 API 相对于之前 FilterSecurityInterCepter/AccessDecisionManager 要使用 metadata sources, config attributes, decison managers, voters 进行授权判断要简化很多。
  5. 因为通过把 AuthenticationManger 放在 Supplier 中实现了对认证数据的延迟访问,这对一些无需认证的授权是有好处的。
  6. 支持基于 Bean 的配置。

3. AuthorizationManagers/AuthorizationFilter 以及 AccessDecisionManager/FilterSecurityInterceptor

AuthorizationManagers 被 AuthorizationFilter 来使用,负责做出是否允许访问的决定。
为了保证向后兼容,FilterSecurityInterceptor 作为用于授权的 security fitler 依旧是默认的 Spring Security Filters 之一,在倒数第二的位置上。

... ...
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
SwitchUserFilter

4. 如何在 Security Filters 中使用 AuthorizationFilter 或 FilterSecurityInterceptor?

在通过 HttpSecurity 构建 SecurityFilterChain 的时候调用authorizeHttpRequests() 就会在 security fitler chain 中插入AuthorizationFilter,而调用****authorizeRequests()****则会插入 security filter FilterSecurityInterceptor

@Bean
SecurityFilterChain web(HttpSecurity http) throws AuthenticationException {
    http
        .authorizeHttpRequests((authorize) -> authorize
            .anyRequest().authenticated();
        )
        // ...

    return http.build();
}

5. 详细的使用范式

关于 AuthorizationFilter/AuthorizationManger,可参考:https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html

关于 FilterSecurityInterceptor,可参考: https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-requests.html



References:
[1]: https://docs.spring.io/spring-security/reference/servlet/authorization/index.html

SpringSecurity里的filer们

这段文字主要源于对 https://docs.spring.io/spring-security/reference/servlet/architecture.html 的学习和理解,其实就是对下图的理解。
SecurityFilterChain
上图表达了下面几个类之间的关系:
DelegatingFilterProxy, FilterChainProxy (springSecurityFilterChain), SecurityFilterChain (security filter)
通过调用 SecurityFilterChain API 把 Security Filters 组装成一个或多个 chain,再设置给 FilterChainProxy 使用。

对于下图这样 FilterChainProxy 有多个 SecurityFilterChain 的情况,只会触发第一个匹配的 securityFilterChain。
Multiple SecurityFilterChain

1)使用 Spring Security

要使用 Spring Security,如果是在 Spring Boot 环境那么只需要导入 security 的 starter,Spring Boot 就会自动做下面的事。
Creates a servlet Filter as a bean named springSecurityFilterChain. This bean is responsible for all the security (protecting the application URLs, validating submitted username and passwords, redirecting to the log in form, and so on) within your application.
Registers the Filter with a bean named springSecurityFilterChain with the Servlet container for every request.
Creates a UserDetailsService bean with a username of user and a randomly generated password that is logged to the console.

不使用 Spring Boot 的情况下,就需要自己在 web.xml 文件中定义 springSecurityFilterChain。

<filter>
  <filter-name>springSecurityFilterChain</filter-name>
  <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
  <filter-name>springSecurityFilterChain</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

2)入口 DelegatingFilterProxy

下面通过源码简单看下 DelegatingFilterProxy 实例化的过程。
Tomcat 启动时会在 web 容器中初始化 DelegatingFilterProxy 实例,

DelegatingFilterProxy 本身既是一个 Filter 也是一个 ServletContextAware 的实例。 Spring 使用 ContextLoaderListener 来加载 spring 的 bean。org.springframework.web.context.support.GenericWebApplicationContext 则是 servlet context 和 spring context 真正交汇的地方。
从下面 DelegatingFilterProxy 实现的接口就可以感知到 DelegatingFilterProxy 最关键的作用就是作为Servlet Container 和 Spring Context 的桥梁
因为 Spring 要等 web context 初始化完成才能初始化自己的 context,所以在 spring 中定义的 filter beans 就要延迟初始化才行。通过延迟初始化就解决了 Filter 必须定义在 Servlet Container 中的问题。 Spring 很巧妙的通过 FilterChain 接口把这些 filter beans 串在一起

public class DelegatingFilterProxy extends GenericFilterBean

而 ServletContextAware 是 Spring 的一个接口。

public abstract class GenericFilterBean implements Filter, BeanNameAware, EnvironmentAware,
    EnvironmentCapable, ServletContextAware, InitializingBean, DisposableBean

从 DelegatingFilterProxy 的两个关键 fields:WebApplicationContext webApplicationContext 和 Filter delegate,也能知道 DelegatingFilterProxy 桥梁作用。

上图中的 delegate 是 FilterChainProxy 的实例。

3) FilterChainProxy 包装了 filers。

下图中 filterChian 包含的 filters 是不做任何特殊配置时的 16 个 filters。FilerChainProxy 就是通过调用它拥有的 filters 起到了对 request 做 filter 处理的作用,这就是称它为代理的原因.

4)springSecurityFilterChain 进入 DelegatingFilterProxy

上面提到 DelegatingFilterProxy 对 filter 延迟初始化的作用,所以对 delegate 的赋值只发生处理第一个 http request 时。被命名为 springSecurityFilterChain 的 FilterChainProxy 会从 spring context 中被找出来并设置到 DelegatingFilterProxy 的 delegate field。

至此,应该对文首的第一个图能说出点儿什么了吧… …

DelegatingFilterProxy 的 field ‘Filter delegate’ 就是名为springSecurityFilterChain 的 FilterChainProxy实例
而 springSecurityFilterChain 默认包含 16 个SecurityFilterChain


Reference:
[1]: https://www.baeldung.com/spring-web-contexts
[2]: https://docs.spring.io/spring-security/reference/servlet/architecture.html
[3]: https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-security-filters

The HttpSecurity class in Spring Security

在项目中实际使用 Spring Security 时,我们的大部分工作其实都是配置 HttpSecurity。要么通过 spring 的 http xml element 来配置,要么通过配置类里的 HttpSecurity class 来配置,所以在理解了 DelegatingFilterProxy,FilterChainProxy,SecurityFilterChain 之间的关系之后就很有必要了解一下 HttpSecurity 类了。

HttpSecurity 这个类的名称与它的实际功用相差甚远,其实把它称为 HttpSecurityFilterChainBuiler 应该更合适,因为它的作用就是利用构造器模式构造出 SecurityFilterChain 的一个实例供 FilterChainProxy 使用。这点从它的类签名就能看出来。

如果有多个 SecurityFilterChain 被配置、构造出来,它们的顺序可以通过注解 @Order 来设定。没有@Order 注解的优先级最低。同一 order 层级的,就可以通过 SecurityFilterChain 中的 RequestMatcher 来决定了该 chain 是否与 http request 匹配了。我们应该尽量把特殊的匹配放在前面,通用的放在后面。

1) 体会下 HttpSecurity 源码的定义部分:

HttpSecurity

2) 用 xml 配置 http security:

虽然现在基于 Spring 的开发都是基于注解的了,但是如果遇到遗留系统里通过 http 元素来定义 HttpSecurity,那么俯视一下下面的 schema 应该也能大致了然了。。。
http element in security namespace

3) fitlers 的顺序定义:

FilterOrderRegistration

4) HttpSecurity build filter 的套路

看看与 authentication 相关的两个 fitler 的构建。

1)从两个 filter 看规律

Filter 都是根据 Configurer 构建出来的。我们以 BasicAuthenticationFilter 和 UsernamePasswordAuthenticationFilter 的 Configurer 举例。
FormLoginConfigurer 比较“特殊”,它定义了两个 filter。一个是 UsernamePasswordAuthenticationFilter, 一个是 DefaultLoginPageGeneratingFilter,后者提供了一个让用户输入 credential 页面的 filter。
HttpBasicConfigurer 则定义了 BasicAuthenticationFilter。

public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
  return getOrApply(new FormLoginConfigurer<>());
}

public HttpBasicConfigurer<HttpSecurity> httpBasic() throws Exception {
  return getOrApply(new HttpBasicConfigurer<>());
}

FormLoginConfigurer 是 AbstractAuthenticationFilterConfigurer 的子类, HttpBasicConfigurer 是 AbstractHttpConfigurer。这是因为 basic 的认证方式比起 form 形式的认证要简单得多。

public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
    AbstractAuthenticationFilterConfigurer<H, FormLoginConfigurer<H>, UsernamePasswordAuthenticationFilter> {

public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
    extends AbstractHttpConfigurer<T, B> {

public final class HttpBasicConfigurer<B extends HttpSecurityBuilder<B>> extends AbstractHttpConfigurer<HttpBasicConfigurer<B>, B> {

负责根据这些 configuer 构造出对象来的类是 AbstractConfiguredSecurityBuilder。

public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>> extends AbstractSecurityBuilder<O> {

2) 没有被使用的 AuthenticationFilter

很奇怪在 Spring Security 的源码里没有看到 AuthenticationFilter 被使用。估计这是要让程序员通过提供自定义的 authenticationConverter 和 authenticationManagerResolver 来使用吧。
对比三个与 authentication 相关的 fitler 体会下。

BasicAuthenticationFilter

UsernamePasswordAuthenticationFitler
UsernamePasswordAuthenticationFitler

AuthenticationFilter
AuthenticationFilter




References:
[1]: https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#jc-httpsecurity
[2]: https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#_multiple_httpsecurity
[3]: https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#jc-custom-dsls
[4]: https://www.baeldung.com/spring-onceperrequestfilter

Apereo CAS 之 支持OAuth2

Apereo CAS 通过使用 bridge 模式来支持多个协议:CAS、SAML2、OAuth2、OpenID Connect 等。
CAS 可部署软件包中已经包含了可以使用 SAML2、OAuth2 等协议的 plugin/bridges/modules,这些 plugins 模块都是和 CAS 通信。 可参考:https://apereo.github.io/cas/6.5.x/protocol/Protocol-Overview.html。

The right-hand side of that equation is always CAS when you consider, as an example, the following authentication flow with an OAuth2-enabled client application:

  1. The CAS deployment has turned on the OAuth2 plugin.
  2. An OAuth2 authorization request is submitted to the relevant CAS endpoint.
  3. The OAuth2 plugin verifies the request and translates it to a CAS authentication request!
  4. The authentication request is routed to the relevant CAS login endpoint.
  5. User authenticates and CAS routes the flow back to the OAuth2 plugin, having issued a service ticket for the plugin.
  6. The OAuth2 plugin attempts to validate that ticket to retrieve the necessary user profile and attributes.
  7. The OAuth2 plugin then proceeds to issue the right OAuth2 response by translating and transforming the profile and validated assertions into what the client application may need.

1. 添加依赖库

implementation "org.apereo.cas:cas-server-support-oauth-webflow"

2. Enable Actuator Endpoints (Optional)

添加依赖,并设置开放 oauthd 的 actuator 端点。

implementation "org.apereo.cas:cas-server-support-reports"

management.endpoint.oauthTokens.enabled=true
management.endpoints.web.exposure.include=oauthTokens
cas.monitor.endpoints.endpoint.oauthTokens.access=PERMIT

通过访问 https://localhost:8443/cas/actuator/ 应该可以看到 OAuth 相关 endpoints。

3. 定义一个 OAuth Client

可以通过设置

cas.service-registry.json.location=classpath:/services
cas.service-registry.core.init-from-json=true

在 cas-overlay-template 的 resources/services 下定义文件 OAuth2DemoClient-2001.json 包含以下内容来把这个 OAuth2 Client ‘OAuth2DemoClient’ 自动导入到 MongoDB 的 cas_serviceregistry collection。

{
  "@class" : "org.apereo.cas.support.oauth.services.OAuthRegisteredService",
  "clientId": "oauth2DemoClientID",
  "clientSecret": "clientSecret",
  "serviceId" : "^(https|imaps)://<redirect-uri>.*",
  "name" : "OAuth2DemoClient",
  "id" : 2001,
  "supportedGrantTypes": [ "java.util.HashSet", [ "password", "authorization_code", "client_credentials", "refresh_token"] ],
  "supportedResponseTypes": [ "java.util.HashSet", [ "token", "code", "device_code"] ]
}

grant 是获得 AccessToken 的方式/方法,这篇文章对此进行了详细介绍:https://alexbilbie.com/guide-to-oauth-2-grants/。

4. 重启、查看

运行 ./gradlew clean copyCasConfiguration build run 后,查看 db.getCollection('cas_serviceregistry').find({}) 应该可以看到 id 为 2001 的 OAutho client 定义。

通过 CAS Management UI 也可以看到刚刚添加的‘OAuth2DemoClient’:
An OAuth Client/Service

至此,我们以及把 Apereo CAS 配置成支持 OAuth2 协议。



Reference:
[1]: https://apereo.github.io/cas/6.5.x/protocol/Protocol-Overview.html
[2]: https://alexbilbie.com/guide-to-oauth-2-grants/
[3]: https://dacurry-tns.github.io/deploying-apereo-cas/building_server_service-registry_configure-the-service-registry.html

Performance Test 101

无论是哪种测试:单元测试、集成测试、回归测试等,目的都是找到 breaking points、消除性能瓶颈。

对于 Performance Test 来说,我们希望在期望的负载下验证我们的系统是否在 速度伸缩性稳定性 方面满足系统对性能的需求。一般来说我们希望系统能在 RPS 和 latency 之间达到一个不错的平衡。

最最基础的两个指标:

RPS (request per sec):
每秒系统可接受/处理的请求数。值得注意的时,虽然请求可被接收或者处理,但是处理的结果可能是正常启动的 value 也有可能因为系统压力的原因而是 Error。

这篇文章讲了RPS 和 Virtual User 的概念 ,可以帮助理解为什么不是 RPS 越高越好而是 RPS 与 Latence 之间的平衡。其中讲到 Virutal User 的生命周期是到测试结束,这一点对我们理解和写测试代码挺重要的。
The typical virtual user life cycle looks like this — it picks up the scripted tasks and performs them, once it finishes all of them it will start the loop again. And it keeps it going until the test is terminated.

这个和 Locust 文档 中描述的行为是一致的: This user will make HTTP requests to /hello, and then /world, again and again.

Latency:
从请求被创建出来发送到接收的 endpint 开始到收到 response 的这个段时间。

1. 性能测试类型

这里列出的类型分类是从稍微具体一点的关注点来分类的。我们做性能测试就是希望在下面几个方面得到系统当前的处理能力,以及它的 breaking points。

  • 负载测试 Load Test

  • 压力测试 Stress Test

  • 耐久/疲劳测试 Endurance Test

  • 尖峰测试 Spike Test

  • 容量 Volume Test

  • 伸缩测试 Scaliability Test

Load testing – checks the application’s ability to perform under anticipated user loads. The objective is to identify performance bottlenecks before the software application goes live.

Stress testing – involves testing an application under extreme workloads to see how it handles high traffic or data processing. The objective is to identify the breaking point of an application.

Endurance testing – is done to make sure the software can handle the expected load over a long period of time.

Spike testing – tests the software’s reaction to sudden large spikes in the load generated by users.

Volume testing – Under Volume Testing large no. of. Data is populated in a database and the overall software system’s behavior is monitored. The objective is to check software application’s performance under varying database volumes.

Scalability testing – The objective of scalability testing is to determine the software application’s effectiveness in “scaling up” to support an increase in user load. It helps plan capacity addition to your software system.

2. 性能测试的三个方面

上面列出的几个测试关注点,都可以放到这三点:速度、伸缩性、稳定性。
速度: 负载测试, 压力测试
伸缩: 压力测试, Spike Test, Volume Test, Scaliability Test
稳定性: Endurance testing

1. 有多快?—— 速度

常用指标:

  • 加载时长 Load time
  • 响应时长 Response time
  • RPS
  • 每秒网络字节数 Network bytes total per second
  • 网络输出队列长度 Network output queue length

只看平均值是没有太多意义的,要看百分比分布。

2. 还能有多快? —— 伸缩性

这里的 scalability 是说在外部请求不断增加的情况下,系统的资源分配是否能支持这些增加的负载。同样我们也期望当外部负载变小时,系统占用的资源也能够释放出来。
这里的 memeory footprint、CPU usage、bandwidth usage 都是系统垂直伸缩的指标。
如果系统支持水平伸缩,那么就要看系统是否能够在输入负载增加的情况下,系统是否能够通过水平扩展节点来保持期望的处理速度。

常用指标:

  • 内存
  • 带宽
  • CPU
  • 并发用户数
  • 系统处理节点
  • … …

3. 可以持续多久?—— 稳定性

持续的给系统一定的输入负载,看看系统是否能够正常处理请求。一般来说,通过这种持续性测试可以发现系统是否能够对使用后的资源进行了释放,比如内存泄露的情况。
如果使用 Gatling,对应如下语句进行 stability 的测试。

jumpToRps(20),
holdFor(Duration.ofSeconds(30)),

常用指标:

  • Page faults
  • Committed memory
  • Maximum active sessions
  • Thread counts
  • Transactions passed or failed
  • Error rate

3. 性能测试步骤

1. Identify your testing environment

软硬件环境、网络配置、测试工具。

2. Identify the performance acceptance criteria

针对 吞吐量、响应时间、资源占用等,设定被测程序可接受的性能指标。比如:

  • 1000 个用户同时访问网站时,响应时间不超过 4s。
  • 应用崩溃前可处理器的最大并发用户数。
  • 在峰值负载的情况下,CPU、内存的使用情况
  • 测试应用在低、一般、大、超大负载情况下应用的响应时间、吞吐量。

3. 规划设计性能测试方案

对于测试指标因为人力、需求的不同,也没必要面面俱到, 根据需要和场景重点设计需要的测试类型:比如 Load Test, Endurance Test 等

4. 配置测试环境

5. 实现并运行测试

6. Analyze,Tune,Retest



Reference:
[1]: https://www.guru99.com/performance-testing.html
[2]: https://www.onpathtesting.com/blog/performance-testing-metrics
[3]: https://nbomber.com/docs/loadtesting-basics/
[4]: https://medium.com/@novyludek/virtual-users-vs-rps-77627b384127