2024年11月

技术背景


上一篇博客
中我们介绍了CudaSPONGE的基本安装和使用方法。为了性能考虑,CudaSPONGE是基于纯CUDA C开发的,但是现在很多轮子都是Python开发的。为兼容更多的框架和平台,CudaSPONGE也提供了相应的Python API,方便Python开发者调用与二次开发。

接口逻辑

虽然安装和操作的过程并不复杂,但是这里面的交互逻辑还是得大概梳理一下。CudaSPONGE本身支持从plugin中调用几个固定的接口函数,如
Calculate_Force()
用于更新作用力,还有
Mdout_Print()
打印输出回调函数等等。调用的方式是通过动态链接库加载,也就是说,plugin的开发逻辑是先有一个python文件或者C语言文件,其中的API要跟CudaSPONGE对齐,然后编译成so动态链接库,供CudaSPONGE模拟的过程去调用,这是一个CudaSPONGE plugin开发的逻辑链条。

此外还有另外一个形式的plugin开发,可以参考本文的参考链接1中的内容,CudaSPONGE官方提供了一个prips插件,这个Python插件的逻辑是两头调用,本质上是对上述动态链接库接口的进一步封装。这就使得我们可以直接从Python文件中调用相应的接口函数,而不需要再编译成一个动态链接库文件,大大简化了Python Plugin开发的工作量。两种模式的差异如下图所示(非官方,个人理解):

prips安装与测试

prips插件支持pip直接安装:

$ python3 -m pip install prips
Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Collecting prips
  Downloading http://mirrors.aliyun.com/pypi/packages/d1/c0/35e829fb82fd6d4bcb5debd0a0fa7cfeec85325f9d015a2babb68123a3ee/prips-1.4.tar.gz (78 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 78.4/78.4 kB 1.9 MB/s eta 0:00:00
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Installing backend dependencies ... done
  Preparing metadata (pyproject.toml) ... done
Building wheels for collected packages: prips
  Building wheel for prips (pyproject.toml) ... done
  Created wheel for prips: filename=prips-1.4-cp37-cp37m-linux_x86_64.whl size=4182966 sha256=dec62c0a31359dfda10b67f1cac94a4648e800284bc575014ad24f5984acb393
  Stored in directory: /root/.cache/pip/wheels/66/cd/8d/0fe470330380020b3d2395589216cb37387027eb687a203672
Successfully built prips
Installing collected packages: prips
Successfully installed prips-1.4

测试安装可以直接在命令行中执行:

$ python3 -c "import prips"

  PRIPS: Python Runtime Interface Plugin of SPONGE

Version: 1.4
Path: /usr/local/python-3.7.5/lib/python3.7/site-packages/prips/_prips.so

Error: 
    PRIPS replies on the python package "cupy".
    Please install cupy

这里发现少装了一个cupy,那就用pip装一个跟本地CUDA驱动匹配的cupy版本:

$ python3 -m pip install cupy-cuda11x
Looking in indexes: http://mirrors.aliyun.com/pypi/simple/
Collecting cupy-cuda11x
  Downloading http://mirrors.aliyun.com/pypi/packages/31/36/38a34d8bf2bcf9ac44be99c072e6a97bf882892ac506fa69cb70925a845f/cupy_cuda11x-11.6.0-cp37-cp37m-manylinux1_x86_64.whl (90.2 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 90.2/90.2 MB 3.8 MB/s eta 0:00:00
Requirement already satisfied: numpy<1.27,>=1.20 in /usr/local/python-3.7.5/lib/python3.7/site-packages (from cupy-cuda11x) (1.21.6)
Collecting fastrlock>=0.5 (from cupy-cuda11x)
  Using cached http://mirrors.aliyun.com/pypi/packages/42/4e/8bff5aa98ba1406c23a7dded13fea0bf2f536b4f8f7096fcbea0303e9cf5/fastrlock-0.8.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_24_x86_64.whl (47 kB)
Installing collected packages: fastrlock, cupy-cuda11x
Successfully installed cupy-cuda11x-11.6.0 fastrlock-0.8.2

再次运行测试:

$ python3 -c "import prips"

  PRIPS: Python Runtime Interface Plugin of SPONGE

Version: 1.4
Path: /usr/local/python-3.7.5/lib/python3.7/site-packages/prips/_prips.so

Usage:
    1. Copy the path printed above
    2. Paste it to the value of the command "plugin" of SPONGE

这表示安装成功了,并且给出了具体的动态链接库地址,方便我们直接把这个动态链接库地址拷贝到CudaSPONGE运行的mdin配置文件中。

CudaSPONGE-pyplugin测试

如果上述章节显示安装成功,并且本地已经配置好CudaSPONGE的环境,那就可以开始测试简单案例了。例如使用
上一篇博客
中的能量极小化的案例,来一个基础的CudaSPONGE版本的
hello world

case1 Minimization

mode = Minimization 
minimization_dynamic_dt 1
default_in_file_prefix = protein/case1

pbc=0 
cutoff=999

dt = 1e-02
step_limit = 500
write_information_interval = 50

rst = restart

coordinate_in_file =  protein/case1_coordinate.txt
plugin = /usr/local/python-3.7.5/lib/python3.7/site-packages/prips/_prips.so
py = test.py

其中
test.py
的文件内容为:

import Sponge

def Mdout_Print():
    print("Hellow SPONGE World!")

直接使用
$ ../SPONGE -mdin minimize.txt
命令行运行结果输出为:

------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =              50,            time =           0.000,     temperature =            0.00, 
      potential =       120784.66,              LJ =          191.83,         Coulomb =         -134.70, 
        nb14_LJ =       119114.87,         nb14_EE =          -20.69,            bond =            4.54, 
          angle =         1535.60,        dihedral =           93.22, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             100,            time =           0.000,     temperature =            0.00, 
      potential =        47867.34,              LJ =          192.20,         Coulomb =         -134.83, 
        nb14_LJ =        46200.27,         nb14_EE =          -20.75,            bond =            8.92, 
          angle =         1528.36,        dihedral =           93.18, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             150,            time =           0.000,     temperature =            0.01, 
      potential =        21746.57,              LJ =          192.48,         Coulomb =         -134.96, 
        nb14_LJ =        20079.19,         nb14_EE =          -20.75,            bond =           15.94, 
          angle =         1521.55,        dihedral =           93.14, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             200,            time =           0.000,     temperature =            0.02, 
      potential =        11373.98,              LJ =          192.42,         Coulomb =         -135.10, 
        nb14_LJ =         9703.54,         nb14_EE =          -20.71,            bond =           26.00, 
          angle =         1514.73,        dihedral =           93.10, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             250,            time =           0.000,     temperature =            0.05, 
      potential =         7180.14,              LJ =          191.38,         Coulomb =         -135.23, 
        nb14_LJ =         5504.79,         nb14_EE =          -20.65,            bond =           39.29, 
          angle =         1507.51,        dihedral =           93.06, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             300,            time =           0.000,     temperature =            0.13, 
      potential =         5464.48,              LJ =          187.58,         Coulomb =         -135.35, 
        nb14_LJ =         3785.66,         nb14_EE =          -20.68,            bond =           55.14, 
          angle =         1499.09,        dihedral =           93.04, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             350,            time =           0.000,     temperature =            0.32, 
      potential =         4587.00,              LJ =          177.15,         Coulomb =         -135.43, 
        nb14_LJ =         2914.02,         nb14_EE =          -20.95,            bond =           71.93, 
          angle =         1487.24,        dihedral =           93.03, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             400,            time =           0.000,     temperature =            0.63, 
      potential =         3817.87,              LJ =          154.56,         Coulomb =         -135.67, 
        nb14_LJ =         2169.29,         nb14_EE =          -21.40,            bond =           92.39, 
          angle =         1465.65,        dihedral =           93.05, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             450,            time =           0.000,     temperature =            0.96, 
      potential =         2990.47,              LJ =          119.14,         Coulomb =         -136.70, 
        nb14_LJ =         1380.29,         nb14_EE =          -21.51,            bond =          136.01, 
          angle =         1420.15,        dihedral =           93.09, 
------------------------------------------------------------------------------------------------------------
Hellow SPONGE World!
           step =             500,            time =           0.000,     temperature =            1.11, 
      potential =         2276.00,              LJ =           82.45,         Coulomb =         -139.29, 
        nb14_LJ =          725.04,         nb14_EE =          -20.48,            bond =          215.85, 
          angle =         1319.28,        dihedral =           93.16, 
------------------------------------------------------------------------------------------------------------

可以看到,在每一个打印环节都会调用py文件中的打印内容。如果需要看帮助文档,可以将
test.py
的内容修改为:

import Sponge
help(Sponge.controller)
help(Sponge.cv_controller)
help(Sponge.md_info)

但是这里我建议要看文档还是直接进Gitee仓库直接看。关于这里面可以访问的force的数据类型,我们也可以打印出来看一下:

import Sponge

Sponge.controller.Step_Print_Initial("Force_TYPE", "%s")

def Mdout_Print():
    Sponge.controller.Step_Print("Force_TYPE", type(Sponge.md_info.frc))

输出内容为:

------------------------------------------------------------------------------------------------------------
           step =             500,            time =           0.000,     temperature =            1.11, 
      potential =         2276.00,      Force_TYPE = <class 'cupy.ndarray'>,              LJ =           82.45, 
        Coulomb =         -139.29,         nb14_LJ =          725.04,         nb14_EE =          -20.48, 
           bond =          215.85,           angle =         1319.28,        dihedral =           93.16, 
------------------------------------------------------------------------------------------------------------

可以看到是一个封装好的cupy的数组类型。那么我们也可以查看相应参量的Shape:

import Sponge

Sponge.controller.Step_Print_Initial("Force_Shape_0", "%d")
Sponge.controller.Step_Print_Initial("Force_Shape_1", "%d")

def Mdout_Print():
    Sponge.controller.Step_Print("Force_Shape_0", Sponge.md_info.frc.shape[0])
    Sponge.controller.Step_Print("Force_Shape_1", Sponge.md_info.frc.shape[1])

输出内容大概是这样的:

------------------------------------------------------------------------------------------------------------
           step =              50,            time =           0.000,     temperature =            0.00, 
      potential =       120784.66,   Force_Shape_0 =              57,   Force_Shape_1 =               3, 
             LJ =          191.83,         Coulomb =         -134.70,         nb14_LJ =       119114.87, 
        nb14_EE =          -20.69,            bond =            4.54,           angle =         1535.60, 
       dihedral =           93.22, 
------------------------------------------------------------------------------------------------------------

这个Shape也就是我们输入的分子体系的Shape了。

为了方便查看结果,我们把mdin改成单步的优化:

case1 Minimization

mode = Minimization 
minimization_dynamic_dt 1
default_in_file_prefix = protein/case1

pbc=0 
cutoff=999

dt = 1e-02
step_limit = 1
write_information_interval = 1

rst = restart

coordinate_in_file =  protein/case1_coordinate.txt
plugin = /usr/local/python-3.7.5/lib/python3.7/site-packages/prips/_prips.so
py = test.py

然后输出一个force求和的数据结果:

import Sponge

Sponge.controller.Step_Print_Initial("Force_SUM", "%2f")

def Mdout_Print():
    Sponge.controller.Step_Print("Force_SUM", Sponge.md_info.frc.sum())

输出结果为:

------------------------------------------------------------------------------------------------------------
           step =               1,            time =           0.000,     temperature =            0.00, 
      potential =       424228.34,       Force_SUM =       -0.258057,              LJ =          191.39, 
        Coulomb =         -134.55,         nb14_LJ =       422551.28,         nb14_EE =          -20.52, 
           bond =            2.78,           angle =         1544.69,        dihedral =           93.28, 
------------------------------------------------------------------------------------------------------------

可以看到正常大概是在
0.x
这个数量级,如果我们对这个force进行操作,使用
Calculate_Force()
函数将其放大100倍,再看看结果:

import Sponge

Sponge.controller.Step_Print_Initial("Force_SUM", "%2f")

def Calculate_Force():
    Sponge.md_info.frc *= 100

def Mdout_Print():
    Sponge.controller.Step_Print("Force_SUM", Sponge.md_info.frc.sum())

输出内容为:

------------------------------------------------------------------------------------------------------------
           step =               1,            time =           0.000,     temperature =            0.00, 
      potential =       424228.34,       Force_SUM =      -30.750000,              LJ =          191.39, 
        Coulomb =         -134.55,         nb14_LJ =       422551.28,         nb14_EE =          -20.52, 
           bond =            2.78,           angle =         1544.69,        dihedral =           93.28, 
------------------------------------------------------------------------------------------------------------

可以看到这是一个被放大100倍之后的结果,数量级已经不一样了。关于CudaSPONGE-python调用的案例就先介绍这么多,在这个基础上去扩展一些Force Wrapper的应用应该是很容易的。

总结概要

本文介绍了高性能GPU分子动力学模拟软件CudaSPONGE的Python API接口,通过官方开发的prips插件,使得我们可以在Python框架下很方便的开发一些分子动力学模拟的Force Wrapper,例如Meta Dynamics中就有很多可以外界的工具,非常方便开发者的二次开发,同时又能够兼顾到性能。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/sponge-python.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html

参考链接

  1. https://gitee.com/gao_hyp_xyj_admin/prips

这两天没有上传笔记,在解决图床的问题,主打一个白嫖,所以要费点心思,先是用了gitee的图床好不容易配好后发现居然加了防盗链,后面又转了github的咱目前来说github也是最稳定且免费的,但是有个问题,这玩意得用梯子才能看到,作为国内的博客谁随时挂个梯子上来,所以最后还是用了收点费不是很多,但能很省心的阿里云oss存储方案

1.轨道控制器

1 控制物体移动

前面我们创建了物体,为了让物体移动起来。我们可以设置它的position属性进行位置的设置。

相机和立方体都是物体。每个物体都是1个对象。

在官方文档里,我们可以看到相机camera和物体mesh都继承Object3D类。所以camera、mesh都属于3d对象。从3d对象的官方文档里,我们可以找到position属性,并且该属性一个vector3对象。因此通过官方vector3类的文档,我们可以简单使用下面2种方式来修改position位置,当然后面还会讲解更多的方式。

//设置该向量的x、y 和 z 分量。
mesh.position.set(x,y,z);
//直接设置position的x,y,z属性
mesh.position.x = x;
mesh.position.y = y;
mesh.position.z = z;

官方文档:
https://threejs.org/docs/index.html?q=vect#api/zh/math/Vector3

1.1 每一帧修改一点位置形成动画

例如,每一帧让立方体向右移动0.01,并且当位置大于5时,从0开始。那么可以这么设置。

function render() {
  cube.position.x += 0.01;
  if (cube.position.x > 5) {
    cube.position.x = 0;
  }
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

2 综合上述代码

1、在前面创建的项目中的main.js文件写入代码

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// console.log(THREE);

// 目标:控制3d物体移动

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);

// 修改物体的位置
// cube.position.set(5, 0, 0);
cube.position.x = 3;

// 将几何体添加到场景中
scene.add(cube);

console.log(cube);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器  
const controls = new OrbitControls(camera, renderer.domElement);

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

function render() {
  cube.position.x += 0.01;
  if (cube.position.x > 5) {
    cube.position.x = 0;
  }
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

效果演示:

3.总结

就能放大缩小画面了

image-20241115205706667

自动在中心原点处

image-20241115205843624

设置位置,还可以父子级网格,子级是相对于父级来设置位置

image-20241115210215338

子级相较于父级的正3位置所以还是原点

image-20241115210246725

2.物体缩放与旋转

物体的缩放与旋转是我们经常要操作的方式。

1 scale设置缩放

因为物体的scale属性是vector3对象,因此按照vector的属性和方法,设置x/y/z轴方向的缩放大小。

//例如设置x轴放大3倍、y轴方向放大2倍、z轴方向不变
cube.scale.set(3, 2, 1);
//单独设置某个轴的缩放
cube.scale.x = 3

2 rotation设置旋转

因为的旋转通过设置rotation属性,该属性是Euler类的实例,因此可以通过Euler类的方法进行设置旋转角度。

因此可以通过以下方式设置旋转物体

//直接设置旋转属性,例如围绕x轴旋转90度
cube.rotation.x = -Math.PI/2

//围绕x轴旋转45度
cube.rotation.set(-Math.PI / 4, 0, 0, "XZY");

set方法,每个参数具体定义

.
set
( x :
Float
, y :
Float
, z :
Float
, order :
String
) :
this

x
- 用弧度表示x轴旋转量。

y
- 用弧度表示y轴旋转量。

z
- 用弧度表示z轴旋转量。

order
- (optional) 表示旋转顺序的字符串。

2.1 旋转动画

每一帧旋转弧度制的0.01角度,实现动画代码

function render() {
  cube.position.x += 0.01;
  cube.rotation.x += 0.01;
  if (cube.position.x > 5) {
    cube.position.x = 0;
  }
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

3 综合上述代码

1、在前面创建的项目中的main.js文件写入代码

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

// console.log(THREE);

// 目标:控制3d物体移动

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);

// 修改物体的位置
// cube.position.set(5, 0, 0);
cube.position.x = 3;

// 将几何体添加到场景中
scene.add(cube);

console.log(cube);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

function render() {
  cube.position.x += 0.01;
  if (cube.position.x > 5) {
    cube.position.x = 0;
  }
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

效果演示:

4.总结

缩放也是较于父级,父级变大,子级跟着大

image-20241115210609890

3.自适应屏幕大小

1.1 自适应屏幕大小

你会发现,我们前面写好的代码,在页面尺寸发生改变的时候,并不能自适应的改变尺寸,而出现空白或者滚动条突出的情况。所以监听屏幕大小的改变,来重新设置相机的宽高比例和渲染器的尺寸大小,代码如下:

// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
  //   console.log("画面变化了");
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  //   更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  //   更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  //   设置渲染器的像素比
  renderer.setPixelRatio(window.devicePixelRatio);
});

aspect属性是设置摄像机视锥体的长宽比,通常是使用画布的宽/画布的高。camera.updateProjectionMatrix()用于更新摄像机投影矩阵,相机任何参数被改变以后必须被调用

1.2 控制场景全屏

经常我们需要全屏的展示三维场景。例如,我们想要双击,实现全屏效果,代码如下:

window.addEventListener("dblclick", () => {
  const fullScreenElement = document.fullscreenElement;
  if (!fullScreenElement) {
    //   双击控制屏幕进入全屏,退出全屏
    // 让画布对象全屏
    renderer.domElement.requestFullscreen();
  } else {
    //   退出全屏,使用document对象
    document.exitFullscreen();
  }
});

fullscreenElement
只读属性返回当前在此文档中以全屏模式显示的元素。

如果文档当前未使用全屏模式,则返回值为null。

使用
element.requestFullscreen()
方法以全屏模式查看元素,exitFullscreen方法退出全屏。

2 综合上述代码

1、在前面创建的项目中的main.js文件写入代码

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 导入动画库
import gsap from "gsap";

// console.log(THREE);

// 目标:js控制画面全屏

// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 10);
scene.add(camera);

// 添加物体
// 创建几何体
const cubeGeometry = new THREE.BoxGeometry(1, 1, 1);
const cubeMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00 });
// 根据几何体和材质创建物体
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);

// 修改物体的位置
// cube.position.set(5, 0, 0);
// cube.position.x = 3;
// 缩放
// cube.scale.set(3, 2, 1);
// cube.scale.x = 5;
// 旋转
cube.rotation.set(Math.PI / 4, 0, 0, "XZY");

// 将几何体添加到场景中
scene.add(cube);

console.log(cube);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// console.log(renderer);
// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

// // 使用渲染器,通过相机将场景渲染进来
// renderer.render(scene, camera);

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼,让控制器更有真实效果,必须在动画循环里调用.update()。
controls.enableDamping = true;

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);
// 设置时钟
const clock = new THREE.Clock();

window.addEventListener("dblclick", () => {
  const fullScreenElement = document.fullscreenElement;
  if (!fullScreenElement) {
    //   双击控制屏幕进入全屏,退出全屏
    // 让画布对象全屏
    renderer.domElement.requestFullscreen();
  } else {
    //   退出全屏,使用document对象
    document.exitFullscreen();
  }
  //   console.log(fullScreenElement);
});

function render() {
  controls.update();
  renderer.render(scene, camera);
  //   渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

// 监听画面变化,更新渲染画面
window.addEventListener("resize", () => {
  //   console.log("画面变化了");
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  //   更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  //   更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  //   设置渲染器的像素比
  renderer.setPixelRatio(window.devicePixelRatio);
});

效果演示:

应对HD-DPI显示器

HD-DPI代表每英寸高密度点显示器(视网膜显示器)。它指的是当今大多数的Mac和windows机器以及几乎所有的智能手机。

浏览器中的工作方式是不管屏幕的分辨率有多高使用CSS像素设置尺寸会被认为是一样的。 同样的物理尺寸浏览器会渲染出字体的更多细节。

使用three.js有多种方法来应对HD-DPI。

第一种就是不做任何特别的事情。这可以说是最常见的。 渲染三维图形需要大量的GPU处理能力。移动端的GPU能力比桌面端的要弱。至少截止到2018年, 手机都有非常高的分辨率显示器。 目前最好的手机的HD-DPI比例为3x,意思是非高密度点显示器上的一个像素在高密度显示器上是9个像素。 意味着需要9倍的渲染。

计算9倍的像素是个大工程所以如果保持代码不变我们将计算一个像素然后浏览器将以三倍大小绘制(3x3=9像素)。

对于大型的three.js应用来说上面就是你想要的否侧你的帧速率会很低。

尽管如此如果你确实想用设备的分辨率来渲染,three.js中有两种方法来实现。

一种是使用renderer.setPixelRatio来告诉three.js分辨率的倍数。 访问浏览器从CSS像素到设备像素的倍数然后传给three.js。

renderer.setPixelRatio(window.devicePixelRatio);

之后任何对renderer.setSize的调用都会神奇地使用您请求的大小乘以您传入的像素比例. 强烈不建议这样。 看下面。

另一种方法是在调整canvas的大小时自己处理。

function resizeRendererToDisplaySize(renderer) {
      const canvas = renderer.domElement;
      const pixelRatio = window.devicePixelRatio;
      const width = canvas.clientWidth * pixelRatio | 0;
      const height = canvas.clientHeight * pixelRatio | 0;
      const needResize = canvas.width !== width || canvas.height !== height;
      if (needResize) {
        renderer.setSize(width, height, false);
      }
      return needResize;
    }

第二章方法从客观上来说更好。为什么?因为我拿到了我想要的。 在使用three.js时有很多种情况下我们需要知道canvas的绘图缓冲区的确切尺寸。 比如制作后期处理滤镜或者我们在操作着色器需要访问gl_FragCoord变量,如果我们截屏或者给GPU 读取像素,绘制到二维的canvas等等。 通过我们自己处理我们会一直知道使用的尺寸是不是我们需要的。 幕后并没有什么特殊的魔法发生。

3.其他补充

3.1 gui调试工具

image-20241115220219132

image-20241115221840993

image-20241115221832251

image-20241115222155709

3.2 创建顶点,形成三角形

three里面所有材质平面都是由三角形组成
image-20241118205745969

一个面顶点的数量,因为设置了共用顶点

image-20241118205809786

3.3 顶点组

可以一个网格的多个材质拆分开,分别设置,需要设置顶点组,材质所以在的顶点为一个组

image-20241118210130874

创建几何体,在分别创建不同的材质,注意材质用数组

image-20241118210342341

image-20241118210401796

image-20241118210446710

3.4 快速创建几何体

刚才看到的是用材质创建

官网可以快速创建

image-20241118211811954

4.基础材质

image-20241118213320472

基础材质不受光照影响

贴图

image-20241118213405522

加上透明度

image-20241118213422445

透明度贴图,黑色部分完全透明,只剩下白色部分

image-20241118213502815

image-20241118213509915

加上背景,环境以及环境贴图都为同一张鱼眼图后,可让材质如同在这个场景中

image-20241118213629041

高光贴图,越亮的区域反射光线越明显

image-20241118213716547

光照贴图,让材质能够有此贴图的光照效果

image-20241118213748077

代码实现

创建几何体,创建材质,几何体和材质形成物体

image-20241118213912347

加载纹理贴图

image-20241118214011902

允许透明度

image-20241118214058364

image-20241118214043065

加载ao贴图

image-20241118214343414

透明贴图

image-20241118214505728

image-20241118214802119

image-20241118214807540

光照贴图

image-20241118214844829

环境贴图,需要hdr加载器

image-20241118214927473

image-20241118214950629

想让环境包裹场景

image-20241118215026319

还可以在里面设置环境贴图,让材质可以在这个环境反射,透明等更为真实

image-20241118215331467

光照贴图,高光贴图

image-20241118215207611

4.1 纹理属性

如果感觉有色差,那么看下是不是纹理属性的色彩原因,默认是线性,和 srgb,同在50%,srbg分布的更均匀一点,从暗到白

image-20241118220122048

切换方法

image-20241118220145911

4.2 雾

image-20241118220250959

两种,一种线性(慢慢开始有雾),一种指数(很快就被雾包裹)

image-20241118220413328

image-20241118220425318

image-20241118220506312

4.gltf模型加载

GIF

真正的three场景,比如下面这样,并不是一个一个考代码写出来会很累,也很复杂,一般都是用建模软件建好之后,用gitf加载进来

image-20241118220817312

GLTFLoader是Three.js中用于加载glTF 2.0资源的加载器。glTF是一种开放格式规范,旨在更高效地传输和加载3D内容。该格式文件以JSON或二进制(.glb)格式提供,可以包含外部文件存储贴图(.jpg、.png)和额外的二进制数据(.bin)。

一个glTF文件可以包含一个或多个场景,每个场景包括网格、材质、贴图、蒙皮、骨架、变形目标、动画、灯光以及摄像机等组件。这些组件可以通过GLTFLoader进行加载和解析,并在Three.js中使用。

下面是一个简单的案例,演示如何使用GLTFLoader加载模型并在Three.js中进行渲染:

// 创建场景
const scene = new THREE.Scene();

// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 5;

// 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 创建GLTFLoader对象并加载模型
const loader = new THREE.GLTFLoader();
loader.load('model.gltf', function (gltf) {
  // 将模型添加到场景中
  scene.add(gltf.scene);

  // 设置相机位置和渲染目标
  camera.position.x += (Math.sin(Date.now() * 0.001) * 500);
  camera.position.y += (Math.sin(Date.now() * 0.001) * 500);
  camera.lookAt(scene.position);

  // 在每一帧更新时更新相机位置和渲染目标
  function animate() {
    requestAnimationFrame(animate);
    renderer.render(scene, camera);
  }
  animate();
}, undefined, function (error) {
  console.error(error);
});

在这个案例中,我们首先创建了一个场景、一个相机和一个渲染器。然后创建了一个GLTFLoader对象并调用
load()
方法来加载glTF资源。当加载完成时,我们将模型添加到场景中,并设置相机的位置和渲染目标。最后在每一帧更新时调用渲染器的
render()
方法来渲染整个场景。如果加载过程中出现错误,会输出错误信息到控制台。

导进来这个物体也是个组对象,这也是个3D对象,可以去设置属性

image-20241118221449090

image-20241118221501119

要让这个物体的材质有颜色并且可以反射光,可以去设置一个环境贴图,并且球体贴图,让四周都能够染色

image-20241118221636555

image-20241118221644927

解压缩

如果一个场景物体过大需要解压缩

image-20241118221807216

image-20241118221826066

image-20241118221845083

image-20241118221921365

image-20241118221932865

5.补件动画tween

光线投射实现场景交互

要拿到three里面的材质,不能通过事件,只能通过从摄像机射出一个光投射过可以拿到物体

image-20241119203821979

创建三个球

image-20241119203909379

创建射线和鼠标向量

在全局点击事件里面,将当前点击的鼠标坐标转换成three的向量坐标,-1—1的范围,
可以直接记公式

image-20241119204509570

在全局点击事件里面,通过摄像机和鼠标点击位置更新射线,同时计算射线穿过去的物体,
括号里面可以写一个数组为你创建的物体,也可以直接scene.children
会检测到所有穿过去的物体为一个数组

image-20241119205111936

拿到这个物体后就可以去做一些事情,比如让点击到的第一个物体为一个颜色,再次点击回到原来颜色

注意:getHex方法获取到颜色的十六进制

image-20241119205239544

tween 是什么?如何使用?你为什么想用它?

补间(动画)(来自
in-between
)是一个概念,允许你以平滑的方式更改对象的属性。你只需告诉它哪些属性要更改,当补间结束运行时它们应该具有哪些最终值,以及这需要多长时间,补间引擎将负责计算从起始点到结束点的值。

image-20241119205448782

创建实例,什么轴到哪个位置,多少毫秒内完成

image-20241119205707702

例如,
position
对象拥有
x

y
两个坐标:

var position = {x: 100, y: 0}

如果你想将
x
坐标的值从
100
变成
200
,你应该这么做:

// 首先为位置创建一个补间(tween)
var tween = new TWEEN.Tween(position)

// 然后告诉 tween 我们想要在1000毫秒内以动画的形式移动 x 的位置
tween.to({x: 200}, 1000)

一般来说这样还不够,tween 已经被创建了,但是它还没被激活(使用),你需要这样启动:

// 启动
tween.start()

最后,想要成功的完成这种效果,你需要在主函数中调用
TWEEN.update
,如下使用:

animate()

function animate() {
	requestAnimationFrame(animate)
	// [...]
	TWEEN.update()
	// [...]
}

这样在更新每帧的时候都会运行补间动画;经过 1 秒后 (1000 毫秒)
position.x
将会变成
200

image-20241119210307197

image-20241119210447052

除非你在控制台中打印出
x
的值,不然你看不到它的变化。你可能想要使用
onUpdate
回调:

tween.onUpdate(function (object) {
	console.log(object.x)
})

tips:你可能在这里获取不到
object.x
,具体的见我提的这个
issue

这个函数将会在动画每次更新的时候被调用;这种情况发生的频率取决于很多因素 - 例如,计算机或设备的速度有多快(以及如何繁忙)。

到目前为止,我们只使用补间动画向控制台输出值,但是您可以将它与 three.js 对象结合:

var tween = new TWEEN.Tween(cube.position).to({x: 100, y: 100, z: 100}, 10000).start()

animate()

function animate() {
	requestAnimationFrame(animate)
	TWEEN.update()

	threeRenderer.render(scene, camera)
}

在这种情况下,因为 three.js 渲染器将在渲染之前查看对象的位置,所以不需要使用明确的
onUpdate
回调。

你可能也注意到了一些不同的地方:tween.js 可以链式调用! 每个
tween
函数都会返回
tween
实例,所以你可以重写下面的代码:

var tween = new TWEEN.Tween(position)
tween.to({x: 200}, 1000)
tween.start()

改成这样:

var tween = new TWEEN.Tween(position).to({x: 200}, 1000).start()

在将会看到很多例子,所以熟悉它是很好的!比如
04-simplest
这个例子。

tween.js 的动画

Tween.js 不会自行运行。你需要显式的调用
update
方法来告诉它何时运行。推荐的方法是在主动画循环中执行这个操作。使用
requestAnimationFrame
调用此循环以获得最佳的图形性能。

比如之前这个例子:

animate()

function animate() {
	requestAnimationFrame(animate)
	// [...]
	TWEEN.update()
	// [...]
}

如果调用的时候不传入参数,
update
将会判断当前时间点以确定自上次运行以来已经有多久。

当然你也可以传递一个明确的时间参数给
update

TWEEN.update(100)

意思是"更新时间 = 100 毫秒"。你可以使用它来确保代码中的所有时间相关函数都使用相同的时间值。例如,假设你有一个播放器,并希望同步运行补间。 你的
animate
函数可能看起来像这样:

var currentTime = player.currentTime
TWEEN.update(currentTime)

我们使用明确的时间值进行单元测试。你可以看下
tests.ts
这个例子,看看我们如何用不同的值调用
TWEEN.update()
来模拟时间传递。

控制一个补间

start 和 stop

到目前为止,我们已经了解了
Tween.start
方法,但是还有更多的方法来控制单个补间。 也许最重要的一个是
start
对应的方法:
停止
。 如果你想取消一个补间,只要调用这个方法通过一个单独的补间:

tween.stop()

停止一个从未开始或已经停止的补间没有任何效果。 没有错误被抛出。

start
方法接受一个参数
time
。如果你使用它,那么补间不会立即开始,直到特定时刻,否则会尽快启动(i.e 即在下次调用
TWEEN.update
)。

update

补间也有一个更新的方法---这实际上是由
TWEEN.update
调用的。 你通常不需要直接调用它,除非你是个 疯狂的 hacker。

chain

当你顺序排列不同的补间时,事情会变得有趣,例如在上一个补间结束的时候立即启动另外一个补间。我们称这为链式补间,这使用
chain
方法去做。因此,为了使
tweenB

tewwnA
启动:

image-20241119211332110

GIF

tweenA.chain(tweenB)

或者,对于一个无限的链式,设置 tweenA 一旦 tweenB 完成就开始:

tweenA.chain(tweenB)
tweenB.chain(tweenA)

关于无限的链式查看
Hello world

在其他情况下,您可能需要将多个补间链接到另一个补间,以使它们(链接的补间)同时开始动画:

tweenA.chain(tweenB, tweenC)

警告:调用
tweenA.chain(tweenB)
实际上修改了 tweenA,所以 tweenA 总是在 tweenA 完成时启动。
chain
的返回值只是 tweenA,不是一个新的 tween。

repeat

如果你想让一个补间永远重复,你可以链接到自己,但更好的方法是使用
repeat
方法。 它接受一个参数,描述第一个补间完成后需要多少次重复

tween.repeat(10) // 循环10次
tween.repeat(Infinity) // 无限循环

补间的总次数将是重复参数加上一个初始补间。查看
Repeat

yoyo

这个功能只有在独自使用
repeat
时才有效果。 活跃时,补间的行为将像 yoyo 一样,i.e 它会从起始值和结束值之间跳出,而不是从头开始重复相同的顺序。

delay

更复杂的安排可能需要在实际开始运行之前延迟补间。 你可以使用
delay
方法来做到这一点

tween.delay(1000)
tween.start()

将在调用启动方法后的 1 秒钟后开始执行。

控制所有补间

在 TWEEN 全局对象中可以找到以下方法,除了
update
之外,通常不需要使用其中的大部分对象。

TWEEN.update(time)

我们已经讨论过这种方法。 它用于更新所有活动的补间。
如果
time
不指定,它将使用当前时间。

TWEEN.getAll and TWEEN.removeAll

用于获取对活动
tweens
数组的引用,并分别仅从一个调用中将它们全部从数组中删除

TWEEN.add(tween) and TWEEN.remove(tween)

用于将补间添加到活动补间的列表,或者分别从列表中删除特定的补间。

这些方法通常只在内部使用,但是如果您想要做一些有趣的事情,则会被暴露。

控制补间组

使用
TWEEN
单例来管理补间可能会导致包含许多组件的大型应用程序出现问题。 在这些情况下,您可能希望创建自己的更小的补间组。

示例:交叉组件冲突

如果使用
TWEEN
有多个组件,并且每个组件都想管理自己的一组补间,则可能发生冲突。 如果一个组件调用
TWEEN.update()

TWEEN.removeAll()
,则其他组件的补间也将被更新或删除。

创建你自己的补间组

为了解决这个问题,每个组件都可以创建自己的
TWEEN.Group
实例(这是全局的
TWEEN
对象在内部使用的)。 实例化新的补间时,可以将这些组作为第二个可选参数传入:

var groupA = new TWEEN.Group()
var groupB = new TWEEN.Group()

var tweenA = new TWEEN.Tween({x: 1}, groupA).to({x: 10}, 100).start()

var tweenB = new TWEEN.Tween({x: 1}, groupB).to({x: 10}, 100).start()

var tweenC = new TWEEN.Tween({x: 1}).to({x: 10}, 100).start()

groupA.update() // 只更新tweenA
groupB.update() // 只更新tweenB
TWEEN.update() // 只更新tweenC

groupA.removeAll() // 只移除tweenA
groupB.removeAll() // 只移除tweenB
TWEEN.removeAll() // 只移除tweenC

通过这种方式,每个组件都可以处理创建,更新和销毁自己的一组补间。

改变缓动功能

Tween.js 将以线性方式执行值之间的插值(即缓动),所以变化将与流逝的时间成正比。 这是可以预见的,但在视觉上也是相当无趣的。 不要担心 - 使用缓动方法可以轻松更改此行为。 例如:

tween.easing(TWEEN.Easing.Quadratic.In)

这将导致缓慢地开始向最终值变化,向中间加速,然后迅速达到其最终值,相反,
TWEEN.Easing.Quadratic.Out
一开始会加速,但随着值的接近最终放缓。

可用的缓动函数:TWEEN.Easing

tween.js 提供了一些现有的缓动功能。它们按照它们表示的方程式进行分组:线性,二次,三次,四次,五次,正弦,指数,圆形,弹性,背部和弹跳,然后是缓动型:In,Out 和 InOut。

除非您已经熟悉这些概念,否则这些名称可能不会对您说什么,所以您可能需要查看
Graphs
示例,该示例将一个页面中的所有曲线进行图形化,以便比较它们如何看待一瞥。

这些功能是从 Robert Penner 慷慨地提供几年前作为自由软件提供的原始方程派生而来的,但是已经被优化以便与 JavaScript 很好地发挥作用。

image-20241119211028024

使用自定义缓动功能

您不仅可以使用任何现有的功能,还可以提供您自己的功能,只要遵循一些约定即可:

  • 它必须接受一个参数:
    • k
      : 缓动过程,或我们的补间所处的时间有多长。允许的值在[0,1]的范围内。
  • 它必须根据输入参数返回一个值。

不管要修改多少个属性,easing 函数在每次更新时只调用一次。 然后将结果与初始值以及这个值和最终值之间的差值(delta)一起使用,就像这个伪代码一样:

easedElapsed = easing(k);
for each property:
	newPropertyValue = initialPropertyValue + propertyDelta * easedElapsed;

对于更注重性能表现的人来说:只有在补间上调用
start()
时才会计算增量值。

因此,让我们假设您想使用一个缓解值的自定义缓动函数,但是将 Math.floor 应用于输出,所以只返回整数部分,从而产生一种梯级输出:

function tenStepEasing(k) {
	return Math.floor(k * 10) / 10
}

你可以通过简单地调用它的缓动方法来使用它,就像我们之前看到的那样:

tween.easing(tenStepEasing)

查看
graphs for custom easing functions
示例,以查看这个动作(还有一些用于生成步进函数的元编程)。

回调函数

另一个强大的特性是能够在每个补间的生命周期的特定时间运行自己的功能。 当更改属性不够时,通常需要这样做。

例如,假设你正在试图给一些不能直接访问属性的对象设置动画,但是需要你调用 setter。 您可以使用
update
回调来读取新的更新值,然后手动调用 setters。 所有的回调函数都将补间对象作为唯一的参数。

var trickyObjTween = new TWEEN.Tween({
	propertyA: trickyObj.getPropertyA(),
	propertyB: trickyObj.getPropertyB(),
})
	.to({propertyA: 100, propertyB: 200})
	.onUpdate(function (object) {
		object.setA(object.propertyA)
		object.setB(object.propertyB)
	})

或者想象一下,当一个补间开始时,你想播放声音。你可以使用
start
回调:

var tween = new TWEEN.Tween(obj).to({x: 100}).onStart(function () {
	sound.play()
})

每个回调的范围是补间对象--在这种情况下,是
obj

onStart

在补间开始之前执行--i.e. 在计算之前。每个补间只能执行一次,i.e. 当通过
repeat()
重复补间时,它将不会运行。

同步到其他事件或触发您要在补间启动时发生的操作是非常好的。

补间对象作为第一个参数传入。

onStop

当通过
stop()
显式停止补间时执行,但在正常完成时并且在停止任何可能的链补间之前执行补间。

补间对象作为第一个参数传入。

onUpdate

每次补间更新时执行,实际更新后的值。

补间对象作为第一个参数传入。

onComplete

当补间正常完成(即不停止)时执行。

补间对象作为第一个参数传入。

作者:来自 vivo 互联网服务器团队- Xu Yaoming

介绍分布式锁的实现原理。

一、分布式锁概述

分布式锁,顾名思义,就是在分布式环境下使用的锁。众所周知,在并发编程中,我们经常需要借助并发控制工具,如 mutex、synchronized 等,来保障线程安全。但是,这种线程安全仅作用在同一内存环境中。在实际业务中,为了保障服务的可靠性,我们通常会采用多节点进行部署。在这种分布式情况下,各实例间的内存不共享,线程安全并不能保证并发安全,如下例,同一实例中线程A与线程B之间的并发安全并不能保证实例1与实例2之间的并发安全:

图片

因此,当遇到分布式系统的并发安全问题时,我们就可能会需要引入分布式锁来解决。

用于实现分布式锁的组件通常都会具备以下的一些特性:

  • 互斥性
    :提供分布式环境下的互斥原语来加锁/释放锁,当然是分布式锁最基本的特性。

  • 自动释放
    :为了应对分布式系统中各实例因通信故障导致锁不能释放的问题,自动释放的特性通常也是很有必要的。

  • 分区容错性
    :应用在分布式系统的组件,具备分区容错性也是一项重要的特性,否则就会成为整个系统的瓶颈。

目前开源社区中常见的分布式锁解决方案,大多是基于具备集群部署能力的 key-value 存储中间件来实现,最为常用的方案基本上是基于 Redis、zookeeper 来实现,笔者将从上述分布式锁的特性出发,介绍一下这两类的分布式锁解决方案的优缺点。

二、分布式锁的实现原理

2.1  Redis 实现分布式锁

Redis 由于其高性能、使用及部署便利性,在很多场景下是实现分布式锁的首选。首先我们看下 Redis 是如何实现互斥性的。在单机部署的模式下,Redis 由于其单线程处理命令的线程模型,天然的具备互斥能力;而在哨兵/集群模式下,写命令也是单独发送到某个单独节点上进行处理,可以保证互斥性;其核心的命令是 set [NX](set if ot exist):

SET lockKey lockValue NX

成功设置 lockValue 的实例,就相当于抢锁成功。但如果持有锁的实例宕机,因为 Redis 服务端并没有感知客户端状态的能力,因此会出现锁无法释放的问题:

图片

这种情况下,就需要给 key 设置一个过期时间 expireTime:

SET lockKey lockValue EX expireTime NX

如果持有锁的实例宕机无法释放锁,则锁会自动过期,这样可以就避免锁无法释放的问题。在一些简单的场景下,通过该方式实现的分布式锁已经可以满足需求。但这种方式存在一个明显问题:如果业务的实际处理时间比锁过期时间长,锁就会被误释放,导致其他实例也可以加锁:

图片

这种情况下,就需要通过其他机制来保证锁在业务处理结束后再释放,一个常用的方式就是通过后台线程的方式来实现锁的自动续期。

图片

Redssion 是开源社区中比较受欢迎的一个 Java 语言实现的 Redis 客户端,其对 Java 中 Lock 接口定义进行扩展,实现了 Redis 分布式锁,并通过 watchDog 机制(本质上即是后台线程运作)来对锁进行自动续期。以下是一个简单的 Reddison 分布式锁的使用例子:

RLock rLock = RedissonClient.getLock("test-lock");
try {
    if (rLock.tryLock()) {
        // do something
    }
} finally {
    rLock.unlock();
}

Redssion 的默认实现 RedissonLock 为可重入互斥非公平锁,其 tryLock 方法会基于三个可选参数执行:

  • waitTime(获取锁的最长等待时长)
    :默认为-1,waitTime 参数决定在获取锁的过程中是否需要进行等待,如果 waitTime>0,则在获取锁的过程中线程会等待一定时间并持续尝试获取锁,否则获取锁失败会直接返回。

  • leaseTime(锁持有时长)
    :默认为-1。当 leaseTime<=0 时,会开启 watchDog 机制进行自动续期,而 leaseTime>0 时则不会进行自动续期,到达 leaseTime 锁即过期释放

  • unit(时间单位)
    :标识 waitTime 及 leaseTime 的时间单位

我们不妨通过参数最全的 RedissonLock#tryLock(long waitTime, long leaseTime, TimeUnit unit) 方法源码来一探其完整的加锁过程:

public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
    ...
    // tryAcquire方法返回锁的剩余有效时长ttl,如果未上锁,则为null
    Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {
        // 获取锁成功
        return true;
    }
     
    // 计算剩余等待时长,剩余等待时长小于0,则不再尝试获取锁,获取锁失败,后续有多处同样的判断逻辑,将精简省略
   time -= System.currentTimeMillis() - current;
    if (time <= 0) {
        acquireFailed(waitTime, unit, threadId);
        return false;
    }
     
    // 等待时长大于0,则会对锁释放的事件进行订阅,持有锁的客户端在锁释放时会发布锁释放事件通知其他客户端抢锁,由此可得知该默认实现为非公平锁。
    // Redisson对Redis发布订阅机制的实现,底层大量使用了CompletableFuture、CompletionStage等接口来编写异步回调代码,感兴趣的读者可以详细了解,此处不作展开
    CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
    try {
        subscribeFuture.get(time, TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        ...
    } catch (ExecutionException e) {
        ...
    }
 
    try {
        ...
        // 循环尝试获取锁
        while (true) {
            long currentTime = System.currentTimeMillis();
            ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
            // lock acquired
            if (ttl == null) {
                return true;
            }
            ...
            // 此处通过信号量来将线程阻塞一定时间,避免无效的申请锁浪费资源;在阻塞期间,如果收到了锁释放的事件,则会通过信号量提前唤起阻塞线程,重新尝试获取锁;
            currentTime = System.currentTimeMillis();
            if (ttl >= 0 && ttl < time) {
                // 若ttl(锁过期时长)小于time(剩余等待时长),则将线程阻塞ttl
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                // 若等待时长小于ttl,则将线程阻塞time
                commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
            }
            ...
        }
    } finally {
        // 取消订阅
        unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
    }
}

上述代码逻辑主要集中在处理 waitTime 参数,在并发竞争不激烈、可以容忍一定的等待时间的情况下,合理设置 waitTime 参数可以提高业务并发运行成功率,避免抢锁失败直接返回错误;但在并发竞争激烈、对性能有较高要求时,建议不设置 waitTime,或者直接使用没有 waitTime 参数的 lock() 方法,通过快速失败来提高系统吞吐量。

一个比较值得注意的点是,如果设置了 waitTime 参数,则 Redisson 通过将 RedissonLockEntry 中信号量(Semaphore)的许可证数初始化为0来达到一定程度的限流,保证锁释放后只有一个等待中的线程会被唤醒去请求 Redis 服务端,把唤醒等待线程的工作分摊到各个客户端实例上,可以很大程度上缓解非公平锁给 Redis 服务端带来的惊群效应压力。

public class RedissonLockEntry implements PubSubEntry<RedissonLockEntry> {
    ...
    private final Semaphore latch;
 
    public RedissonLockEntry(CompletableFuture<RedissonLockEntry> promise) {
        super();
        //  RedissonLockEntry 中的Semaphore的许可证数初始化为0
        this.latch = new Semaphore(0);
        this.promise = promise;
    }
    ...
}

获取锁的核心逻辑,会通过 RedissonLock#tryAcquire 方法调用到 RedissonLock#tryAcquireAsync 方法。

private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    RFuture<Long> ttlRemainingFuture;
    if (leaseTime > 0) {
        // 若leaseTime大于零,会设置锁的租期为leaseTime
        ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 若leaseTime小于或等于零,会设置锁的租期为internalLockLeaseTime,这是一个通过lockWatchdogTimeout配置的值,默认为30s
        ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    }
 
    // 此处的handleNoSync方法是为了解决Redis发生故障转移,集群拓扑改变后,只有持有锁的客户端能再次获得锁的bug,为3.20.1版本修复,详见Redisson issue#4822
    CompletionStage<Long> s = handleNoSync(threadId, ttlRemainingFuture);
    ttlRemainingFuture = new CompletableFutureWrapper<>(s);
 
    // 根据加锁情况来进行后续处理
    CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {
        // lock acquired
        // 若ttl为空,说明加锁不成功
        if (ttlRemaining == null) {
            if (leaseTime > 0) {
                // 若leaseTime>0,则将internalLockLeaseTime变量设置为leaseTime,以便后续解锁使用
                internalLockLeaseTime = unit.toMillis(leaseTime);
            } else {
                // 若leaseTime<=0,则开启看门狗机制,通过定时任务进行锁续期
                scheduleExpirationRenewal(threadId);
            }
        }
        return ttlRemaining;
    });
    return new CompletableFutureWrapper<>(f);
}
 
// 加锁的lua脚本
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
            "if ((Redis.call('exists', KEYS[1]) == 0) " +
                        "or (Redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
                    "Redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "Redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                "end; " +
                "return Redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

可以看到,若 leaseTime 大于0,则不会开启看门狗机制,锁在过期后即失效,在使用时请务必留意。上述代码中执行的 scheduleExpirationRenewal 方法即为看门狗机制的实现逻辑:

protected void scheduleExpirationRenewal(long threadId) {
    // 每个锁都会对应一个ExpirationEntry类,第一次加锁时不存在oldEntry
    ExpirationEntry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        // 非首次加锁,重入计数,不作其他操作
        oldEntry.addThreadId(threadId);
    } else {
        // 首次加锁,调用renewExpiration()方法进行自动续期
        entry.addThreadId(threadId);
        try {
            renewExpiration();
        } finally {
            // 若当前线程被中断,则取消对锁的自动续期。
            if (Thread.currentThread().isInterrupted()) {
                cancelExpirationRenewal(threadId);
            }
        }
    }
}
 
private void renewExpiration() {
    ...
    // 此处使用的是netty的时间轮来执行定时续期,此处不对时间轮做展开,感兴趣的读者可详细了解
    Timeout task = getServiceManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ...
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock {} expiration", getRawName(), e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                 
                if (res) {
                    // 若续期成功,则递归调用,等待任务的下一次执行
                    renewExpiration();
                } else {
                    // 若续期结果为false,说明锁已经过期了,或锁易主了,则清理当前线程关联的信息,等待线程结束
                    cancelExpirationRenewal(null);
                }
            });
        }
        // 时间轮的执行周期为internalLockLeaseTime / 3,即默认情况下,internalLockLeaseTime为30s时,每10s触发一次自动续期
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
     
    ee.setTimeout(task);
}
 
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    // 执行重置过期时间的lua脚本
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (Redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "Redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

上面一段代码即是看门狗调度的核心代码,本质上即是通过定时调度线程执行 lua 脚本来进行锁续期。值得留意的是 scheduleExpirationRenewal 方法中的 ExpirationEntry,该对象与锁一一关联,会存储尝试获取该锁的线程(无论是否获取成功)以及重入锁的次数,在锁失效/锁释放时,会根据该对象中存储的线程逐一进行资源释放操作,以保证资源的正确释放。

最后,对上述 Redisson 可重入非公平锁源码进行一下总结:

  • Redisson 加锁时,根据 waitTime 参数是否大于0来决定加锁失败时采用等待并再次尝试/快速失败的策略;

  • Redisson 加锁时根据 leaseTime 参数是否小于等于0来决定是否开启看门狗机制进行定时续期;

  • Redisson 底层使用了 netty 实现的时间轮来进行定时续期任务的调度,执行周期为 internalLockLeaseTime / 3,默认为10s。

2.2 zookeeper 实现分布式锁

zookeeper(后文均简称 zk )基于 zab 协议实现的分布式协调服务,天生具备实现分布式锁的基础条件。我们可以从zk的一些基本机制入手,了解其是如何实现分布式锁的。

  • zab
    :为了保证分布式一致性,zk 实现了 zab(Zk Atomic Broadcast,zk 原子广播)协议,在 zab 协议下,zk集群分为 Leader 节点及  Follower 节点,其中,负责处理写请求的 Leader 节点在集群中是唯一的,多个 Follower 则负责同步 Leader 节点的数据,处理客户端的读请求。同时,zk 处理写请求时底层数据存储使用的是 ConcurrentHashMap,以保证并发安全;

public class NodeHashMapImpl implements NodeHashMap {
 
    private final ConcurrentHashMap<String, DataNode> nodes;
    private final boolean digestEnabled;
    private final DigestCalculator digestCalculator;
    private final AdHash hash;
     
    ...
 
}
  • 临时顺序节点
    :zk 的数据呈树状结构,树上的每一个节点为一个基本数据单元,称为 Znode。zk 可以创建一类临时顺序(EPHEMERAL_SEQUENTIAL)节点,在满足一定条件时会可以自动释放;同时,同一层级的节点名称会按节点的创建顺序进行命名,第一个节点为xxx-0000000000,第二个节点则为xxx-0000000001,以此类推;

图片

  • session
    :zk 的服务端与客户端使用 session 机制进行通信,简单来说即是通过长连接来进行交互,zk 服务端会通过心跳来监控客户端是否处于活动状态。若客户端长期无心跳或断开连接,则 zk 服务端会定期关闭这些 session,主动断开与客户端的通信。

了解了上述 zk 特点,我们不难发现 zk 也是具备互斥性、自动释放的特性的。同时,zk 由于 session 机制的存在,服务端可以感知到客户端的状态,因此不需要有由客户端来进行节点续期,zk 服务端可以主动地清理失联客户端创建的节点,避免锁无法释放的问题。zk 实现分布式锁的主要步骤如下:

  1. client1 申请加锁,创建 /lock/xxx-lock-0000000000节点(临时顺序节点),并监听其父节点 /lock;

  2. client1 查询 /lock 节点下的节点列表,并判断自己创建的 /xxx-lock-0000000000 是否为 /lock 节点下的第一个节点;当前没有其他客户端加锁,所以 client1 获取锁成功;

  3. 若 client2 此时来加锁,则会创建 /lock/xxx-lock-0000000001 节点;此时 client2 查询 /lock 节点下的节点列表,此时 /xxx-lock-0000000001 并非 /lock 下的第一个节点,因此加锁不成功,此时 client2 则会监听其上一个节点 /xxx-lock-0000000000;

  4. client1 释放锁,client1 删除 /xxx-lock-0000000000 节点,zk 服务端通过长连接 session 通知监听了 /xxx-lock-0000000000 节点的 client2 来获取锁

  5. 收到释放事件的 client2 查询 /lock 节点下的节点列表,此时自己创建的 /xxx-lock-0000000001 为最小节点,因此获取锁成功。

图片

图片

图片

图片

上述是 zk 公平锁的一种常见实现方式。值得注意的是, zk 客户端通常并不会实现非公平锁。事实上,zk 上锁的粒度不局限于上述步骤中的客户端,zk 客户端每次获取锁请求(即每一个尝试获取锁的线程)都会向 zk 服务端请求创建一个临时顺序节点。

以上述步骤为例,如果需要实现非公平锁,则会导致其余的所有节点都需要监听第一个节点 /xxx-lock-0000000000 的释放事件,相当于所有等待锁释放的线程都会监听同一个节点,这种机制无法像 Redisson 一样把唤醒锁的压力分摊到客户端上(或者说实现起来比较困难),会产生比较严重的惊群效应,因此使用 zk 实现的分布式锁一般情况下都是公平锁。

Curator 是一个比较常用的 zk 客户端,我们可以通过 Curator 的加锁过程,来了解 zk 分布式锁的设计原理。Curator 中比较常用的是可重入互斥公平锁 InterProcessMutex:

InterProcessMutex mutex = new InterProcessMutex(zkClient, "/lock");
try {
    // acquire方法的两个参数:等待时长及时间单位
    if (mutex.acquire(3, TimeUnit.SECONDS)) {
        log.info("加锁成功");
    } else {
        log.info("加锁失败");
    }
} finally {
    mutex.release();
}

InterProcessMutex 同样提供了等待时长参数,用于设置没有立即获取到锁时是快速失败还是阻塞等待,下一步,方法会调用到 InterProcessMutex#internalLock 方法中:

private boolean internalLock(long time, TimeUnit unit) throws Exception
{
    // 注释的意思:一个LockData对象只会被一个持有锁的线程进行修改,因此不需要对LockData进行并发控制。如此说明的原因是zk的互斥特性保证了下方attemptLock方法的互斥,由此保证了LockData不会被并发修改
    /*
        Note on concurrency: a given lockData instance
        can be only acted on by a single thread so locking isn't necessary
    */
 
    Thread currentThread = Thread.currentThread();
     
    // LockData用于记录当前持有锁的线程数据
    LockData lockData = threadData.get(currentThread);
    if ( lockData != null )
    {
        // 线程不为空,则进行重入,重入次数+1
        // re-entering
        lockData.lockCount.incrementAndGet();
        return true;
    }
     
    // 向zk服务获取分布式锁,getLockNodeBytes
    String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
    if ( lockPath != null )
    {
        // 若lockPath不为空,则获取锁成功,记录当前持有锁的线程
        LockData newLockData = new LockData(currentThread, lockPath);
        threadData.put(currentThread, newLockData);
        return true;
    }
 
    return false;
}

InterProcessMutex#internalLock会调用到 LockInternals#attemptLock 方法:

String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception
{
    ...
    while ( !isDone )
    {
        isDone = true;
 
        try
        {
            // 创建锁节点
            ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
            // 判断是否成功获取锁
            hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
        }
        catch ( KeeperException.NoNodeException e )
        {
            // 捕获由于网络中断、session过期等原因导致的无法获得节点异常,此处根据配置的zk客户端重试策略决定是否重试,默认重试策略为Exponential Backoff
            ...retry or not...
        }
    }
 
    if ( hasTheLock )
    {
        return ourPath;
    }
 
    return null;
}
 
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception
{
    String ourPath;
    if ( lockNodeBytes != null )
    {  
        // 在其他类型的锁实现中,lockNodeBytes可能不为空,则根据lockNodeBytes来获取节点路径,此处暂不作展开
        ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path, lockNodeBytes);
    }
    else
    {
        // 在可重入互斥锁中,客户端向zk服务端请求创建一个 EPHEMERAL_SEQUENTIAL 临时顺序节点
        ourPath = client.create().creatingParentContainersIfNeeded().withProtection().withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(path);
    }
    return ourPath;
}

上述代码中,创建锁节点并不会产生互斥,而是会直接向 zk 服务端请求创建临时顺序节点。此时,客户端还未真正的获得锁,判断加锁成功的核心逻辑在 LockInternals#internalLockLoop 方法中:

private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception
{
    boolean     haveTheLock = false;
    boolean     doDelete = false;
    try
    {
        if ( revocable.get() != null )
        {  
            // curator锁撤销机制,通过实现Curator中的Revocable接口的makeRevocable方法,可以将锁设置为可撤销锁,其他线程可以在符合条件时将锁撤销,此处暂不涉及
            client.getData().usingWatcher(revocableWatcher).forPath(ourPath);
        }
         
        // 客户端实例就绪,则尝试循环获取锁
        while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) 
        {
            // 获取当前父节点下的排好序的子节点
            List<String>        children = getSortedChildren();
            // 得到当前节点名
            String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash
            // 根据 children 列表与当前节点名,计算当前节点是否为第一个节点,若不是第一个节点,则在 PredicateResults中返回需要监听的前一个节点节点,若为最小节点,则获取锁成功
            PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
            if ( predicateResults.getsTheLock() )
            {
                // 获取锁成功
                haveTheLock = true;
            }
            else
            {
                // 拼接前一个节点的节点路径
                String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
                 
                synchronized(this)
                {
                    try
                    {
                        // 将前一个节点的监听器放到当前客户端中,当前一个节点被释放时,就会唤醒当前客户端
                        client.getData().usingWatcher(watcher).forPath(previousSequencePath);
                        if ( millisToWait != null )
                        {
                            millisToWait -= (System.currentTimeMillis() - startMillis);
                            startMillis = System.currentTimeMillis();
                            // 计算剩余等待时长,若等待时长小于0,则不再尝试获取锁,并标记当前线程创建的节点需要删除
                            if ( millisToWait <= 0 )
                            {
                                doDelete = true;    // timed out - delete our node
                                break;
                            }
                            // 若等待时长大于0,则阻塞线程,等待锁释放
                            wait(millisToWait);
                        }
                        else
                        {
                            // 在其他的一些加锁场景中,默认会持久等待到锁释放位置,当前可重入互斥锁暂不涉及
                            wait();
                        }
                    }
                    catch ( KeeperException.NoNodeException e )
                    {
                        // it has been deleted (i.e. lock released). Try to acquire again
                    }
                }
            }
        }
    }
    catch ( Exception e )
    {
        ThreadUtils.checkInterrupted(e);
        doDelete = true;
        throw e;
    }
    finally
    {
        if ( doDelete )
        {
            // 删除当前节点
            deleteOurPath(ourPath);
        }
    }
    return haveTheLock;
}
 
private synchronized void notifyFromWatcher()
{
    // 当zk客户端收到锁释放事件时,会遍历当前客户端注册过的所有的监听器,并找到合适的监听器进行回调,最终通过notifyAll唤醒监听被释放节点的线程
    notifyAll();
}

上述 curator 加锁的核心代码虽然比较长,但整体逻辑与我们前面分析过的加锁逻辑是一致的,主要做了三件事:

  • 获取当前父节点的有序子节点序列;

  • 判断当前节点是否为第一个节点;

  • 若为第一个节点,则获取锁成功,否则为当前 zk 客户端增加一个前一节点的监听器,如果此时还在等待时长内,则使用wait方法挂起线程,否则删除当前节点。

三、总结——如何选择合适的分布式并发安全解决方案?

  • 绕不过的 CAP 理论

Redis 与 zk 由于客户端与服务端的交互机制上存在比较大的差异,相应的分布式锁实现原理也有所不同。两者都是优秀的支持分布式部署的系统,自然具备分区容错性,但分布式系统总绕不过去一个经典的问题——CAP理论:在满足了分区容错性的前提下,分布式系统只能满足可用性、数据一致性两者其一。

图片

对比之下,Redis 在可用性上更胜一筹,属于 AP 系统;zk 具备更强的数据一致性,属于 CP 系统,而基于 AP、CP 的特性去实现的分布式锁,自然也会存在不同程度的问题。

  • Redis 分布式锁的一致性问题

Redis 的集群模式并没有严格地实现分布式共识算法,因此 Redis 是不具备一致性的。为了保证高可用性,Redis 集群的主从节点使用的是异步复制,从节点并不保证与主节点数据一致,只能尽量的追赶主节点的最新数据;因此,当主节点发生故障,进行主从切换时,实际上有可能会发生数据丢失问题:

图片

  • zk 性能及可用性问题

zk 实现了 zab 算法,在数据一致性上给出了比较可靠的方案,但是由于 zab 协议的两阶段提交要求所有节点的写请求处理就绪后,才算写入成功,这无疑会导致性能的下降。此外,在zk集群发生 leader 重选举的过程中,对外会表现为不可用状态,此时可用性上就会存在问题:

图片

由上可知,分布式并发安全解决方案并不存在完美的“银弹”,因此更多时候我们应当根据自身业务情况,合理地选择合适的解决方案。

显而易见地,如果业务场景有较高的请求量,并发竞争比较激烈,对性能有较高要求,此时通过 Redis 来实现分布式锁会是比较合适的方案。但是如果业务场景对数据一致性要求比较高,或是系统交互链路比较长,一但发生数据不一致时,会导致系统出现难以恢复的问题时,采用zk来实现分布式锁则是更优的解决方案。

  • 上述方案都无法满足要求?

总体上看,Redis 由于其本身的高性能可以满足大多数场景下的性能要求,而 zk 则保证了较高数据一致性。但倘若遇到了既要求高性能、又要求数据一致性、还要引入锁机制来保障并发安全的场景,这时候就必须重新审视系统设计是否合理了,毕竟高并发与锁是一对矛盾,可用性与数据一致性是一对矛盾,我们应该通过良好的方案、系统设计,来避免让我们的系统陷入这些矛盾的困境中。

大家好,我是汤师爷~

大厂对候选人的要求较高,即使是20k薪资的岗位,也期望应聘者能够独立承担工作职责。

对于30-40k薪资的岗位,需要具备独立系统设计和小型架构设计的能力。

技术专家和架构师岗位(30-50k以上)要求应聘者具有带领团队、负责大型系统架构的经验,并且在架构设计方面有全面且深入的理论知识和实践经验。

今天聊聊中央库存系统应用架构设计,这套架构分为三层:应用层、领域层和对接层。

1、应用层

应用层架构包含3个主要模块:

  • 消费者端:负责处理与消费者直接相关的库存操作,包括商品详情页的库存展示、订单提交时的库存检查和占用、订单支付过程的库存扣减等操作。系统需要确保这些操作的实时性和准确性,为用户提供良好的购物体验。
  • 商家端:提供全面的库存管理功能,包括多渠道库存的查询与同步、供货关系的配置、分配策略制定、安全库存的设置与调整、实物库存的实时查询与同步、库存同步策略的配置,以及发货和退货过程中的库存处理。
  • 三方平台对接:实现与主流平台渠道(如天猫、京东、美团、饿了么等)的库存数据对接,确保跨平台库存数据的准确同步和及时更新,支持全渠道销售策略的实施。该模块需要处理不同平台的接口规范和数据格式,确保库存信息的一致性。

2、领域层

领域层架构包含4个核心功能模块,每个模块都承担着特定的库存管理职责:

  • 调度模块:负责实物库存的全生命周期管理,包括实时库存查询、库存预占与释放操作、完整的库存流水记录、追踪,以及基于业务规则的实物库存动态计算。该模块是确保库存准确性和可追溯性的基础。
  • 销售模块:专注于前端销售场景的库存管理,涵盖销售库存的实时查询、订单相关的库存预占和释放操作、详细的库存流水记录维护,以及针对不同销售渠道的库存计算逻辑实现。该模块直接支撑着销售环节的顺畅运行。
  • 配置模块:主要处理库存管理的策略和规则设置,包括供货关系的灵活配置、多渠道库存分配策略的制定、安全库存水平的设置,以及仓库库存的系统配置管理。该模块为库存管理提供了可配置的策略支持。
  • 同步模块:确保各系统间库存数据的一致性,负责实物库存和渠道库存的自上而下、自下而上的同步,并提供灵活的同步策略设置。该模块是维护全渠道库存数据准确性的关键保障。

3、门店/仓库库存对接

该层主要负责与各类仓储和门店系统的仓库库存数据交互和同步。它通过标准化的接口协议,实现与仓库WMS、门店POS、ERP等系统的库存数据整合,确保了仓库库存数据在整个中央库存系统的准确性和实时性。

本文已收录于,我的技术网站:
tangshiye.cn
里面有,算法Leetcode详解,面试八股文、BAT面试真题、简历模版、架构设计,等经验分享。

前言

常说c#、java是面向对象的语言,但我们平时都是在用面向过程的思维写代码,实现业务逻辑像记流水账一样,大篇if else的判断;对业务没有抽象提炼、代码没有分层。随着需求变化、功能逐步拓展、业务逻辑逐渐复杂;代码越来越长、if else嵌套越来越多,代码会变成程序员都厌恶的"屎山"。这种代码后期维护成本非常高、牵一发而动全身、改一处逻辑战战兢兢。假如我们完成开发任务交差,后期维护不关自己的事;但是长期做重复的CRUD、记流水账对我们没有好处。虽然项目不是自己的,但是时间是自己的,这样几年过去似乎没有精进变化,长期下去年龄增大会逐渐丧失竞争力。

下面记录今天开发的一个小功能,演示一步一步重构的过程。

需求

  1. 有一个智能识别的api给用户调用。角色有两个:管理员、普通用户。管理员不限次数调用,普通用户每日限用五次。

简单实现,只判断如果是普通用户就检查次数,不满足就返回提示:

if (service.isNormalUser() && service.freeNumUseUp()) {
    return AjaxResult.error("普通用户免费识别次数已使用完!");
}

// todo:调用识别接口
  1. 功能演变:普通用户可充值后升级为VIP用户,VIP用户在有效期内不限次数使用,过期以后降为普通用户。
    增加VIP角色的检查后:
if (service.isVipUser() && service.vipUserExpire()) {
    return AjaxResult.error("会员已到期!");
}
if (service.isNormalUser() && service.freeNumUseUp()) {
    return AjaxResult.error("普通用户免费识别次数已使用完!");
}

// todo:调用识别接口

以上修改的问题:普通用户充值以后,是增加一个VIP的角色而不是把原普通用户角色更新为VIP角色。此时这个用户有两个角色,那么上面的代码先判断VIP角色是否到期是没问题的,但是下面又判断了是否为普通用户就有问题了,因为他有两个角色呀,VIP未到期时第2个条件也满足了会给出不合理的提示。怎么改,首先想到的是不是检查VIP后就不检查普通用户了?于是修改为:

if (service.isVipUser()) {
    if (service.vipUserExpire()) {
        return AjaxResult.error("会员已到期!");
    }
} else {
    if (service.isNormalUser() && service.freeNumUseUp()) {
        return AjaxResult.error("普通用户免费识别次数已使用完!");
    }
}

// todo:调用识别接口

以上仍然有问题,如果是VIP角色就不会检查普通用户角色了,可是按需求VIP到期以后他还具有普通用户角色,可以在每天免费次数内使用。于是再改:

boolean dontPass = service.isVipUser() && service.vipUserExpire();
if (dontPass) {
    dontPass = service.isNormalUser() && service.freeNumUseUp();
    if (dontPass) {
        return AjaxResult.error("普通用户免费识别次数已使用完!");
    } else {
        return AjaxResult.error("会员已到期!");
    }
}

// todo:调用识别接口

以上修改可以满足VIP和普通用户的检查了,还差了管理员的判断,还要再嵌套:

boolean dontPass = !service.isAdmin();
if (dontPass) {
    dontPass = service.isVipUser() && service.vipUserExpire();
    if (dontPass) {
        dontPass = service.isNormalUser() && service.freeNumUseUp();
        if (dontPass) {
            return AjaxResult.error("普通用户免费识别次数已使用完!");
        } else {
            return AjaxResult.error("会员已到期!");
        }
    }
}

// todo:调用识别接口

终于满足3个角色的检查了,加了3层if判断。以后再出现新的角色怎么办?如果功能交给同事来升级,原来的代码轻易不敢动只能再嵌套。

梳理以上需求,3个角色有任意一个通过就可以了。实际上检查时可以按以下先后顺序逐个过,最后一个不满足才返回提示。

a. 是否有管理员角色,否进入下一级
b. 是否有VIP角色且未到期,否进入下一级
c. 是否有普通用户角色且满足免费次数条件,否进入下一级;如果没有下一级则检查不通过。

重新设计

  1. 审批角色接口,主要两个功能:a. 角色判断(当前用户是否为本角色),b. 是否检查(审批)通过
public interface IAudit {
    /**
     * 角色判断:是否为我的责任
     *
     * @return
     */
    boolean isMyDuty();
    
    /**
     * 是否通过
     *
     * @return
     */
    boolean auditPass();
    
    /**
     * 检查(审批)意见:不通过时返回空字符串
     *
     * @return
     */
    String auditMessage();
}
  1. 审批角色抽象类,实现审批角色接口,并且是3个角色实现类的父类,充当审批角色接口和角色实现类的中间过度。作用是判断检查(审批)是否通过,这里不大容易理解,实际3个角色的实现类分别实现接口就可以了,没有这个中间过度也可以的。为什么要加这个中间类?因为最终检查是否通过要调用isMyDuty和auditPass两个方法,这里可以把这两个方法的调用合并为一个方法,其实就是把判断角色和角色的检查条件统一在这个类而不是在3个实现类里去分别写了,为什么?因为3个实现类要写的判断都是完全一样的代码
    isMyDuty()&&auditPass()
    ,作用就是本来要写3行,现在只写1行。看上去没有必要?因为现在只有3个类呀,如果以后扩展到5个角色,5类那多了。还有,如果是功能修改呢,那就要6个类里分别改了。每改一个类都需要针对这个类单独测试。修改测试花时间多了,这里只有一次修改测试。
public abstract class AbstractAudit implements IAudit {
    /**
     * 角色是否检查通过
     *
     * @return
     */
    public boolean checkPass() {
        return isMyDuty() && auditPass();
    }
}
  1. 3个角色的实现类。
  • 管理员:
@Service
public class AdminAudit extends AbstractAudit {
    @Autowired
    private IdentifyService identifyService;
    
    @Override
    public boolean isMyDuty() {
        return identifyService.isAdmin();
    }
    
    @Override
    public boolean auditPass() {
        return true;
    }
    
    /**
     * 管理员是没有限制的,所以没有提示
     *
     * @return
     */
    @Override
    public String auditMessage() {
        return "";
    }
}
  • VIP用户:
@Service
public class VipUserAudit extends AbstractAudit {
    @Autowired
    private IdentifyService identifyService;
    
    @Override
    public boolean isMyDuty() {
        return identifyService.isVipUser();
    }
    
    @Override
    public boolean auditPass() {
        return !identifyService.vipUserExpire();
    }
    
    /**
     * 这里还需要优化,因为isMyDuty和auditPass可能被调用两次,可以将isMyDuty、auditPass返回值存在临时变量中
     *
     * @return
     */
    @Override
    public String auditMessage() {
        if (!isMyDuty()) {
            return "不是会员";
        } else if (!auditPass()) {
            return "会员过期";
        }
        return "";
    }
}
  • 普通用户:
@Service
public class NormalUserAudit extends AbstractAudit {
    @Autowired
    private IdentifyService identifyService;
    
    @Override
    public boolean isMyDuty() {
        return identifyService.isNormalUser();
    }
    
    @Override
    public boolean auditPass() {
        return !identifyService.freeNumUseUp();
    }
    
    @Override
    public String auditMessage() {
        return "普通用户免费识别次数已使用完";
    }
}
  1. 审批责任链类。作用为添加审批人、审批返回结果。
public class AuditChain {

    private List<AbstractAudit> chain = new ArrayList<>();

    /**
     * 添加审批人
     *
     * @param auditor
     */
    public void add(AbstractAudit auditor) {
        chain.add(auditor);
    }


    /**
     * 检查/审批
     *
     * @return
     */
    public Result audit() {
        Result result = new Result();
        // 是否检查通过
        boolean pass = chain.stream().anyMatch(a -> a.checkPass());
        result.setPass(pass);
        if (!pass) {
            String msg = chain.stream().map(c -> c.auditMessage()).filter(m -> Strings.isNotBlank(m)).collect(Collectors.joining(","));
            result.setMsg(msg);
        }
        return result;
    }


    @Data
    public class Result {
        private boolean pass;
        private String msg;
    }
}
  1. 实现检查
// 审批责任链中加入3个角色,这里用的Spring Boot开发,3个角色都是容器注入的,其它框架中手动创建实例

// 添加审批人角色
auditChain.add(adminAudit);
auditChain.add(vipUserAudit);
auditChain.add(normalUserAudit);

// 审批结果
AuditChain.Result auditResult = auditChain.audit();
if (!auditResult.isPass()) {
    return AjaxResult.error(auditResult.getMsg());
}

总结

最终的实现代码简洁明了,易维护、易扩展升级:

  1. 核心方法只有auditChain.add和auditChain.audit,一眼看去就能明白作用是加入审批人和实现审批。
  2. 如何扩展功能加入其它角色?创建新的角色类并继承AbstractAudit,并加入到责任链中。不需要在原来的if中嵌套了。
  3. 现在的检查是多个角色中有任意一个通过即可,转换到审批场景就是多角色审批,其中一个角色审批通过即可。如果要需求改成多个角色全部审批通过才行呢?其实就是责任人链中or的关系改为and关系。 只需要修改AuditChain类的audit方法,将
    chain.stream().anyMatch
    改为
    chain.stream().allMatch
    。anyMatch表示任意一个匹配,allMatch表示全部匹配。如果要在改造前的代码中要实现or到and的变化,原有代码几乎要完全重写。

学习交流: