OpenGL坐标系统与渲染管线

前言

  图形学中最基础的东西就是坐标系统,三维的东西如何在二维中显示,这中间经历了数次坐标变换,同时坐标变换也贯穿了整个计算机图形渲染管线。

前言

  图形学中最基础的东西就是坐标系统,三维的东西如何在二维中显示,这中间经历了数次坐标变换,同时坐标变换也贯穿了整个计算机图形渲染管线。

坐标篇

coordinate_systems

  在计算机图形世界中,为更灵活的控制三维物体显示在二维中,将变换的过程大致分为 5 个空间:1、局部空间(Local Space,或者称为物体空间(Object Space));2、世界空间(World Space);3、观察空间(View Space,或者称为视觉空间(Eye Space));4、裁剪空间(Clip Space);5、屏幕空间(Screen Space)。局部空间中是物体相对于坐标原点的坐标,也是物体的固有坐标,在依次经历过缩放旋转平移,也即模型矩阵(Model Matrix)变换后,物体局部坐标变换为世界坐标,世界坐标中即定义了物体所在的位置,以及产生的旋转和缩放。在世界空间中加入相机,以相机的视角看世界中的物体,即通过观察矩阵(View Matrix,也称视图矩阵)变换后,将世界坐标转换为观察坐标,由于一张屏幕能显示的东西是有限的,而三维世界中的物体是无限,所以需要通过投影矩阵(Projection Matrix)对三维空间进行裁剪,以决定哪些物体能显示在屏幕上,为方便的计算机判断,处于裁剪空间内的坐标会被转换为 [-1, 1],为顺利在屏幕上显示,又需要通过视窗变换(Viewport Transform)将 [-1, 1] 映射为 viewport 中的图元坐标,再通过渲染管线的其他流程输出为屏幕上的像素点。

变换篇

  矩阵相乘一般有左乘和右乘之分,左乘和右乘的区别在于坐标是按列还是按行排列(OpenGL 中是按列,所以是左乘,DX 中按行,所以是右乘,同一种变换,传入 DX 中的矩阵与传入 OpenGL 中的矩阵互为转置),坐标与矩阵相乘越靠近坐标的矩阵表示该坐标越先做相应矩阵变换。

  模型矩阵,视图矩阵,投影矩阵,在简单的顶点着色器编程中,这三个矩阵一般会合并成一个 MVP 矩阵传入 GPU 中。

模型矩阵

  模型矩阵一般定义了物体的缩放旋转平移状态,缩放矩阵的构造很简单,若物体在 \((x,y,z)\) 方向上缩放尺度分别为 \((S_x, S_y, S_z)\),则缩放矩阵为: \[ M_{scaling} = \begin{bmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \]   旋转矩阵就非常麻烦了,这里暂且不讨论其如何计算,只给出矩阵,物体绕任意轴 \((R_X, R_y, R_z)\) 旋转 θ 角的矩阵为: \[ M_{rotation} = \begin{bmatrix} cos\theta+R_x^2(1-cos\theta) & R_xR_y(1-cos\theta)-R_zsin\theta & R_xR_z(1-cos\theta)+R_ysin\theta & 0 \\ R_yR_x(1-cos\theta)+R_zsin\theta & cos\theta+R_y^2(1-cos\theta) & R_yR_z(1-cos\theta)-R_xsin\theta & 0 \\ R_zR_x(1-cos\theta)-R_ysin\theta & R_zR_y(1-cos\theta)+R_xsin\theta & cos\theta+R_z^2(1-cos\theta) & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \]   当然,由于万向节锁的存在,一般不会直接使用欧拉角和旋转轴计算旋转矩阵,而是会通过四元数得到旋转矩阵,这样既高效又能避免万向节锁,详情可看「LearnOpenGL」译者的教程

  至于平移矩阵也非常简单,若物体在 \((x,y,z)\) 方向上平移量分别为 \((T_x, T_y, T_z)\),则平移矩阵为: \[ M_{translation} = \begin{bmatrix} 1 & 0 & 0 & T_x \\ 0 & 1 & 0 & T_y \\ 0 & 0 & 1 & T_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \]   前面的缩放和旋转矩阵其实只需要用到 3×3 的矩阵,而之所以用 4×4 的表示也是因为平移矩阵,普通的 3 维坐标必须增加一维 \(w\) 构成齐次坐标才能进行平移操作,\(w\) 一般都是 1.0,而从齐次坐标\((x,y,z,w)\) 变为普通的 3 维坐标需要每个分量除以 \(w\),即 \((x/w, y/w, z/w)\)

则模型矩阵 \(M_{model} = M_{translation} \cdot M_{rotation} \cdot M_{scaling}\)

视图矩阵

  视图矩阵描述的是三维场景中模拟相机的状态,根据模拟相机的状态确定一套以相机为原点的相机坐标系,从而使用视图矩阵进行坐标变换,至于为啥是模拟相机,是因为 OpenGL 本身并没有相机的概念,通过模拟相机来实现在三维场景中的漫游。

camera_axes

  模拟相机有三个关键点,分别为相机位置(cameraPos),相机朝向点(cameraTarget),相机上向量(top),根据相机位置和相机朝向点可确定相机坐标系的 z 轴正向向量 \(cameraDirection = (cameraPos - cameraTarget).normalize\),叉乘相机上向量和相机 z 轴正向向量可得到相机坐标系 x 轴正向向量 \(cameraRight = top.cross(cameraDirection).normalize\),最后将相机 z 轴正向向量与 x 轴正向向量叉乘得到 y 轴正向向量 \(cameraUp = cameraDirection.cross(cameraRight)\),如此即可建立完整的相机坐标系,从而得到变换矩阵,即视图矩阵: \[ M_{view} = \begin{bmatrix} R_x & R_y & R_z & 0 \\ U_x & U_y & U_z & 0 \\ D_x & D_y & D_z & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} 1 & 0 & 0 & -P_x \\ 0 & 1 & 0 & -P_y \\ 0 & 0 & 1 & -P_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \] 其中 \(R\) 是相机 x 轴正向向量,\(U\) 是相机 y 轴正向向量,\(D\) 是相机 z 轴正向向量, \(P\) 是相机位置向量。

投影矩阵

  投影矩阵描述的是摄像机前的可视区域(Frustum),根据可视区域的形状可分为正射投影(Orthographic Projection)和透视投影(Perspective Projection)。

orthographic projection frustum perspective_frustum

  对于这两种投影,都有远(far)近(near)参数,不同的是,正射投影是个立方体,所以有左(left)右(right)上(top)下(bottom)四个参数,而透视投影是个类梯形台,所以还有垂直方向视野(Field of View,fov),以及一个宽高比(aspect)两个参数。远近两个参数决定摄像机能看到多近和多远的物体,太近和太远都会看不见,一般可设 near = 0.1,far = 1000;若渲染视窗(viewport)宽为 W,高为 H,则一般 \(left=-W/2, right=W/2, top=H/2, bottom=-H/2\) ;透视投影的 fov 是角度,一般设为 45.0,而 \(aspect = W/H\) 。这两种投影的矩阵分别为: \[ M_{orth} = \begin{bmatrix} \frac{2}{right-left} & 0 & 0 & -\frac{right+left}{right-left} \\ 0 & \frac{2}{top-bottom} & 0 & -\frac{top+bottom}{top-bottom} \\ 0 & 0 & \frac{-2}{far-near} & -\frac{far+near}{far-near} \\ 0 & 0 & 0 & 1 \end{bmatrix} \\ M_{pers} = \begin{bmatrix} \frac{2near}{right-left} & 0 & \frac{right+left}{right-left} & 0 \\ 0 & \frac{2near}{top-bottom} & \frac{top+bottom}{top-bottom} & 0 \\ 0 & 0 & \frac{-(far+near)}{far-near} & \frac{-2far*near}{far-near} \\ 0 & 0 & -1 & 0 \end{bmatrix} \]

  在 three.js 中,对于透视投影矩阵中 left, right, top, bottom 计算方式为:

1
2
3
4
5
6
let top = near * Math.tan( _Math.DEG2RAD * 0.5 * this.fov ) / this.zoom;
let height = 2 * top;
let width = this.aspect * height;
let left = - 0.5 * width;
let right = left + width;
let bottom = top - height;

  对于透视投影,由于计算出的齐次坐标 w 分量显然不为 1.0,所以必须进行透视除法(x,y,z 各分量分别除以 w),得到真正的 3 维坐标。

  正射投影一般用来模拟 2D 空间,透视投影用来模拟 3D 空间,当透视投影 near 和 far 设置的相差太大时,很容易引发 z-fighting 现象,原因是离近平面越远时,计算出的深度精度越低,three.js 中为解决这一问题,引入了一个 logarithmicDepthBuffer 参数来决定是否开启使用对数函数优化深度计算,具体可看源码中的 logdepthbuf_vertex.glsl.js 和 logdepthbuf_fragment.glsl.js 文件,开启该参数会造成渲染性能下降。

小结

  \(M_{mvp} = M_{projection}M_{view}M_{model}\),一个局部坐标 \(V_{local}\) 在经过 MVP 矩阵变换之后可得到裁剪坐标 \(V_{clip} = M_{mvp}V_{local}\) ,在 OpenGL 中,\(V_{clip}\) 会被赋值到顶点着色器中的 gl_Position,并且 OpenGL 会自动进行透视除法和裁剪。

  3 维中的相机一般可分为两种,第一人称相机(常规 FPS 游戏)和第三人称相机(常规 ARPG 游戏),第一人称相机的特点是灵活,相机往往可以任意改变位置和朝向,所以会对某些人造成一种 “晕 3D” 的现象,而第三人称相机虽然可以改变相机朝向点和位置,但当朝向点和到朝向点的距离一旦固定,则相机只能沿着以朝向点为球心,以到朝向点的距离为半径的球面上运动,这两种相机一般看具体业务需求进行选择。

  缩放操作是很常规的一种操作,镜头拉近代表放大,拉远代表缩小。在使用透视投影的 3 维场景中,只需要改变相机到朝向点的距离即可简单实现缩放操作,而在使用正射投影的场景中,改变距离并不能实现缩放,而是需要改变 左右上下 四个参数,所以在相机中往往会在引入一个 zoom 的参数,用 左右上下 四个参数分别除以 zoom 得到真正的 左右上下,从而改变 zoom,就可以改变相机参数,进而实现正射投影的缩放。

管线篇

顶点着色器图元装配光栅器顶点缓冲区片元着色归属测试模板测试深度测试融合抖动颜色缓冲区纹理缓冲区深度缓冲区uniform数据uniform数据

  渲染管线,图形学中最重要的概念之一,既然称之为管线,自然有像流水线一样的步骤,各个步骤具体做的事情如下:

  1. 顶点着色器:负责将顶点数据进行坐标变换,该着色器中一般存在 MVP 矩阵,负责将三维坐标变换为二维坐标,该阶段也可以优化每个点的深度值,以便管线后续进行深度测试,也可以利用光照简单优化每个顶点的颜色;
  2. 图元装配:将输入的顶点数据进行组装,形成图元,常见的图元包括:点(GL_POINTS)、线(GL_LINES)、线条(GL_LINE_STRIP)、三角面(GL_TRIANGLES),在该过程中,一般 GPU 会做一些裁剪和背面剔除等操作,以减少图元的数量,同时完成透视除法以进行屏幕映射;
  3. 光栅化:负责计算每个图元到屏幕像素点的映射。光栅化会计算每个图元所覆盖的片元,同时利用顶点属性插值计算每个片元的属性,片元可认为是候选像素,经过后续管线阶段即可变为真正的像素。
  4. 片元着色器:将光栅化得到的片元进行颜色计算。图形学中几乎所有的高级特效都会在这一步完成,光照计算,阴影处理,纹理,材质,统统在这一步进行处理;
  5. 归属测试:即测试片元所在位置是否位于当前上下文视窗内,若一个显示帧缓冲区视窗被另一个视窗所遮蔽,则剔除该部分片元。
  6. 模板测试:即测试片元是否满足一定条件(可大于或小于某个值等),若测试不满足,则剔除该该片元, OpenGL 可自行选择开启或关闭模板测试。
  7. 深度测试:用来测试片元的远近,远的片元被遮挡。在深度测试,若两片元深度值接近,则可能会引起 Z-fighting 现象,即像素闪烁,这是因为此时 GPU 无法确定该剔除哪个片元,导致这一帧可能绘制这个片元,下一帧绘制另一个片元。若开启 Alpha 测试,即启用透明度,则会在下一阶段进行 Alpha 混合,从而达到透明效果。
  8. 混合:将新生成的片元颜色和帧缓冲区中对应位置的颜色进行混合,得到像素颜色。
  9. 抖动:一种以牺牲分辨率为代价来增加颜色表示范围技术,从视觉效果上来看就是颜色过度更平滑。

  以上这些阶段中,能完全被编程控制的也就顶点着色器和片元着色器两个阶段,其余阶段要么完全无法控制,要么只能通过已有的参数进行设置,当然也可以通过顶点着色器和片元着色器影响余下阶段,顶点着色器和片元着色器也统称 Shader 编程。

  有时候为了做更好看的特效,需要进行多次渲染,将上一次渲染的结果作为下一次渲染的输入,此时可以将颜色缓冲区作为一张纹理,并构造新的帧缓冲区,将该纹理作为输入,重新放进渲染管线中,这种操作方式也叫后期处理(Post Processing),虽然好看,但对 GPU 的负载很大,需要合理使用。

  对于渲染管线,Shaun 的理解也就到此为止了,非常粗浅,Shader 也只是刚入门的水平,Shaun 在图形学方面做的更多是降低 Draw-Call 和 CPU 层面的 Tessellation,以及 Geometry 上的事,对纹理材质颜色光照阴影等方面涉及的较少。

后记

  虽然目前 OpenGL 已停止更新,但学习图形学编程,OpenGL 总是绕不过去(至少暂时以及未来很长一段时间都会是这样),而且图形学基础知识本质都是相同的,不管是 DirectX 还是 Vulkan,变的只是写法形式而已,数学知识总是在那里,两种 shader 也同样需要,所以了解这些东西还是有必要的。

附录

二维图像的图像透视投影变换

  图像的透视投影变换常用于图像的矫正,OpenCV 中就有现成的 api(getPerspectiveTransform 和 warpPerspective),用于将不规整的四边形区域变换为规整的矩形区域。其基本的数学原理为,先构造一个投影变换等式: \[ \begin{bmatrix} XW \\ YW \\ W \end{bmatrix} = \begin{bmatrix} a & b & c \\ d & e & f \\ g & h & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} \] 设四边形中四个点分别为 \((X_1, Y_1),(X_2, Y_2),(X_3, Y_3),(X_4, Y_4)\) ,对应矩形中四个点为 \((x_1, y_1),(x_2, y_2),(x_3, y_3),(x_4, y_4)\)。则可构造齐次线性方程组: \[ \begin{bmatrix} x_1 & y_1 & 1 & 0 & 0 & 0 & -X_1x_1 & -X_1y_1 \\ 0 & 0 & 0 & x_1 & y_1 & 1 & -Y_1x_1 & -Y_1y_1 \\ x_2 & y_2 & 1 & 0 & 0 & 0 & -X_2x_2 & -X_2y_2 \\ 0 & 0 & 0 & x_2 & y_2 & 1 & -Y_2x_2 & -Y_2y_2 \\ \vdots & \vdots & \vdots & \vdots & \vdots & \vdots & \vdots & \vdots \\ x_n & y_n & 1 & 0 & 0 & 0 & -X_nx_n & -X_ny_n \\ 0 & 0 & 0 & x_n & y_n & 1 & -Y_nx_n & -Y_ny_n \end{bmatrix} \begin{bmatrix} a \\ b \\ c \\ d \\ e \\ f \\ g \\ h \end{bmatrix} = \begin{bmatrix} X_1 \\ Y_1 \\ X_2 \\ Y_2 \\ \vdots \\ X_n \\ Y_n \end{bmatrix} \] 解这个方程组得到 abcdefg ,使用上面的投影变换等式可计算 \(X = XW / W, Y = YW / W\) ,从而使用插值得到规整矩形图形的各个像素值。

Shader 学习资料

shader 入门书:https://thebookofshaders.com,在线编写 shader :https://thebookofshaders.com/edit.php

glslsandbox 网站:http://glslsandbox.com/

shadertoy 网站:https://www.shadertoy.com/

参考资料

[1] 坐标系统(https://learnopengl-cn.github.io)

[2] WebGL图形系统、渲染管线_郭隆邦技术博客

[3] OpenGL Projection Matrix

[4] WebGL着色器32位浮点数精度损失问题

[5] Transform quadrilateral into a rectangle?

Scala 学习小结

前言

  最近要改行做大数据相关的东西了,经调研大数据开发的语言还是用 Scala 好,当然 Java 也可以,毕竟都运行在 JVM 上,不过 Java 也有很长时间没用过了,所以对于 Shaun 来说用 Scala 和 Java 的代价是一样的,都需要学习一下,所以决定用对大数据更友好的 Scala。

前言

  最近要改行做大数据相关的东西了,经调研大数据开发的语言还是用 Scala 好,当然 Java 也可以,毕竟都运行在 JVM 上,不过 Java 也有很长时间没用过了,所以对于 Shaun 来说用 Scala 和 Java 的代价是一样的,都需要学习一下,所以决定用对大数据更友好的 Scala。

  以 Martin Odersky 14 年写的「Scala By Example」为参考,虽然是 14 年的,但 Scala 的基本语法还是没变的,就学习本身而言没问题,毕竟不兼容的只是更上层的 API,Shaun 学习用的 Scala 版本为 2.12.12。Alvin Alexander 的「Scala Cookbook, 2nd Edition」预计今年 8 月会出版,到时可能这本书用来入门更好,但 Shaun 不需要系统的学,就简单的能上手写出比较理想的 Scala 代码就行了。

学习篇

第一章:入门基础

HelloWorld

  由于「Scala By Example」第一章没啥内容,也为了在正式写 Scala 之前简单熟悉一下,这里先用「A Scala Tutorial for Java Programmers」简单上手一下,首先写个 HelloWorld,具体代码如下:

1
2
3
4
5
object HelloWorld {
def main(args: Array[String]) {
println("Hello, world!")
}
}

  和 C 语言类似,程序唯一入口函数都是 main 函数,但 Scala 的变量在前,声明的类型在后,相比常规的语言是有点奇怪了,但这种语法规则和 Typescript 一样,所以很容易接受,但其模板的表示就有点奇怪了,Array[String] 表示一个 String 类型的数组,即表示方法为 Array[T],常规的模板方式为 Array<T>T[],def 关键字用来定义一个函数,object 用来表示一个单例类,即在定义类的同时,又创建了一个类的实例。Scala 中没有 static 关键字,需要用 static 修饰的都放在 object 中即可。

调用 Java

Scala 中默认已导入 java.lang 中的全部类,但其它类需要显式导入,以格式化输出本地日期为例:

1
2
3
4
5
6
7
8
9
10
import java.util.{Date, Locale}
import java.text.DateFormat._

object LocalDate {
def main(args: Array[String]) {
val now = new Date
val df = getDateInstance(LONG, Locale.CHINA)
println(df format now) // df format(now)
}
}

  Scala 中的导入和 java 中 import 基本一样,但功能更强大,可以使用 {} 导入部分,也使用 _ 导入全部(java 导入全部为 *,这不一样),当一个函数只有一个参数,可以通过 空格+参数 的形式调用,而不需要使用 括号包裹 的形式。这里采用 val 关键字声明的是常量,而要声明变量需要用 var

对象

Scala 中万物皆对象,一个数字也是一个对象,一个函数也是一个对象,具体如下图:

enter image description here

以简单计时器函数为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object Timer {
def oncePerSecond(callback: () => Unit) {
while (true) {
callback();
Thread sleep 1000;
}
}

def timeFiles() {
println("time files like an arrow...");
}

def main(args: Array[String]) {
// oncePerSecond(timeFiles);
oncePerSecond(() => {
println("time files like an arrow...");
});
}
}

  这个和 Typescript 函数式编程的用法基本差不多,唯一不同这里声明的函数返回的是 Unit ,这个 Unit 可认为是无返回的函数,大部分情况等同于 void,在 Scala 中真正的没有值指的是 Nothing。

Scala 中同样有类,具体代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Complex(real: Double, imaginary: Double) {
// def re() = real;
// def im() = imaginary;
def re = real;
def im = imaginary;

override def toString(): String = "" + re + (if (im < 0) "" else "+") + im + "i";
}

object ComplexNumbers {
def main(args: Array[String]) {
val c = new Complex(1.2, -3.4);
// println("real part: " + c.re() + " imaginary part: " + c.im());
println(c.toString());
}
}

  在 Scala 中所有类都会继承某个父类,若没有显式声明父类,则默认继承 scala.AnyRef 类,如上面的 Complex 类,若需要覆盖父类的函数,则需要在函数声明前加上 override 关键字。当函数没有参数时,可以不用加括号,在调用时也不用加括号,如上面示例的注释和非注释的代码。

模式匹配与条件类

  接下来用 Scala 来写一个树结构表示表达式的示例代码,树的非叶节点表示操作符,叶子节点表示数值(这里为常量或变量),具体代码如下:

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
abstract class Tree
case class Sum(l: Tree, r: Tree) extends Tree
case class Var(n: String) extends Tree
case class Const(v: Int) extends Tree

object Expression {
type Environment = String => Int

def eval(t: Tree, env: Environment): Int = t match {
case Sum(l, r) => eval(l, env) + eval(r, env)
case Var(n) => env(n)
case Const(v) => v
}

def derive(t: Tree, v: String): Tree = t match {
case Sum(l, r) => Sum(derive(l, v), derive(r, v))
case Var(n) if (v == n) => Const(1)
case _ => Const(0)
}

def main(args: Array[String]) {
val exp: Tree = Sum(Sum(Var("x"), Var("x")), Sum(Const(7), Var("y")))
val env: Environment = {case "x" => 5 case "y" => 7}
println("Expression: " + exp)
println("Evalution with x=5, y=7: " + eval(exp, env))
println("Derivative relative to x:\n" + derive(exp, "x"))
println("Derivative relative to y:\n" + derive(exp, "y"))
}
}

  该示例主要用来说明两种 case 关键字,分别为:case class 和 ... match case ...,前者可认为是一个结构体,实例化时可以省略 new 关键字,参数有默认的 getter 函数,整个 case class 有默认的 equals 和 hashCode 方法实现,通过这两个方式可实现根据值判断类的两个实例是否相等,而不是通过引用,条件类同样有默认的 toString 方法实现;后者可认为是一种特殊的 switch case ,只不过 case 的判定和执行是函数式的,case class 可直接参与 match case 的判定(判定是不是属于该类)。第 7 行中有个 type 关键字,可认为是定义了一种新的类型(不是数据类型),示例中是函数类型,通过这个 type ,可直接将字符串映射为整型,23 行中将这个 type 与 case 结合使用,定义多个字符串映射多个整型的变量。第 18 行中有个 _ ,这是 scala 中的通配符,不同的语义下表示的含义不同,这里的含义是指,当上面的模式都不匹配时,将执行这个,相当于 switch case 中的 default。

Scala 中的 trait

  简单理解就是 Java 中的 Interface(接口),Scala 中没有 interface 关键字,但是 trait 比 Interface 的功能更多,其中可直接定义属性和方法的实现,Scala 中可通过 trait 来实现多重继承。下面的示例用 trait 简单实现了一个比较接口:

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
40
trait Ord {
def <(that: Any): Boolean
def <=(that: Any): Boolean = (this < that) || (this == that)
def >(that: Any): Boolean = !(this <= that)
def >=(that: Any): Boolean = !(this < that)
}

class Date(y: Int, m: Int, d: Int) extends Ord {
def year = y
def month = m
def day = d

override def toString(): String = year + "-" + month + "-" + day

override def equals(that: Any): Boolean = {
that.isInstanceOf[Date] && {
val o = that.asInstanceOf[Date]
o.day == day && o.month == month && o.year == year
}
}

def <(that: Any): Boolean = {
if (!that.isInstanceOf[Date]) {
sys.error("cannot compare " + that + " and a Date")
}

val o = that.asInstanceOf[Date]
(year < o.year) || (year == o.year && (month < o.month || (month == o.month && day < o.day)))
}
}

object Comparable {
def main(args: Array[String]) {
val d1 = new Date(2021, 1, 3);
val d2 = new Date(2021, 1, 3);

println(d1 < d2)
println(d1 <= d2)
}
}

  比较关系一般只需要确定 小于 和 等于 关系即可,其它关系都可由这两关系推出来,由于等于方法默认存在于所有对象中,所以只需要重写小于即可, 其它的比较方法都可以在 trait 中定义好。在上面的示例中有两个函数 isInstanceOf 和 asInstanceOf,前者用来判断对象是否是指定类型,后者用来将对象转换为指定类型,一般用在将父类转为子类时,在使用 asInstanceOf 之前一般需要先使用 isInstanceOf。

泛型

  这东西没啥好说的,基本有编程经验的或见过或用过,只是 Scala 的泛型语法确实有点奇怪就是了,可能也是为了函数式那些乱七八糟的操作符,具体示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Reference[T] {
private var contents: T = _
def set(value: T) {
contents = value
}
def get: T = contents
}

object IntegerReference {
def main(args: Array[String]) {
val cell = new Reference[Int]
cell.set(13)
println("Reference contains the half of " + (cell.get * 2))
}
}

  这里同样有个 _,这里表示的是默认值,对于数字类型来说是 0,对于 boolean 来说是 false,对于 Unit(函数签名)来说是()(无参数无返回),对于其他来说是 null。

简单的了解 Scala 就到这里了。


第二章:快排

开场就是一个快排,示例代码如下:

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
40
41
42
43
44
object QuickSort {
def qSort(xs: Array[Int]) {
def swap(i: Int, j: Int) {
val t = xs(i); xs(i) = xs(j); xs(j) = t;
}

def sort(l: Int, r: Int) {
val pivot = xs(l);
var i = l+1; var j = r;
while (i < j) {
while (i <= r && xs(i) < pivot) i += 1;
while (j > l && xs(j) > pivot) j -= 1;

if (i < j) {
swap(i, j);
i += 1;
j -= 1;
}

if (i > j) {
i = j;
}
}
while (i > l && xs(i) > pivot) {
i -= 1; j -= 1;
}
swap(i, l);

if (l < j-1) sort(l, j-1);
if (j+1 < r) sort(j+1, r);
}

sort(0, xs.length-1);
}

def main(args: Array[String]) {
// val xs = Array(4, 1, 2, 5, 6);
// val xs = Array(1, 2, 4, 4, 55, 5, 6);
// val xs = Array(55, 6, 6);
val xs = Array(4, 1, 5, 7,7,7,7, 2, 6);
qSort(xs);
println(xs.mkString(" "))
}
}

  从这段快排代码可看出,Scala 支持函数嵌套和闭包,即在函数内部定义子函数,子函数可直接使用父函数的变量,同时,这里也简单说明一下 Scala 中数组的一些使用方法,用下标取数组元素时使用的是小括号 (),而不是其它语言常见的中括号 []。当然 Scala 作为一种函数式语言,提供了非常多的函数式操作符,这篇也只会简单介绍。

第三章:Actor

  Actor,Scala 中的多线程编程模型,下方的示例代码在 Scala 2.11 及之后的版本无法运行,因为 Actor 已从 Scala 库独立出来,见 object-actors-is-not-a-member-of-package-scala

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import scala.actors.Actor

abstract class AuctionMessage
case class Offer(bin: Int, client: Actor) extends AuctionMessage
case class Inquire(client: Actor) extends AuctionMessage

abstract class AuctionReply
case class Status(asked: Int, expire: Date) extends AuctionReply
case object BestOffer extends AuctionReply
case class BeatenOffer(maxBid: Int) extends AuctionReply
case class AuctionConCluded(seller: Actor, client: Actor) extends AuctionReply

case object AuctionFailed extends AuctionReply
case object AuctionOver extends AuctionReply


class Auction(seller: Actor, minBid: Int, closing: Date) extends Actor {
val timeToShutdown = 36000000 // msec
val bidIncrement = 10

def act() {
var maxBid = minBid - bidIncrement
var maxBidder: Actor = null
var running = true

while (running) {
receiveWithin ((closing.getTime() - new Date().getTime())) {
case Offer(bid, client) => {
if (bid >= maxBid + bidIncrement) {
if (maxBid >= minBid) maxBidder ! BeatenOffer(bid)
maxBid = bid; maxBidder = client; client ! BestOffer
} else {
client ! BeatenOffer(maxBid)
}
}
case Inquire(client) => {
client ! BeatenOffer(maxBid)
}
case TIMEOUT => {
if (maxBid >= minBid) {
val reply = AuctionConCluded(seller, maxBidder)
maxBidder ! reply; seller ! reply
} else {
seller ! AuctionFailed
}

receiveWithin(timeToShutdown) {
case Offer(_, client) => client ! AuctionOver
case TIMEOUT => running = false
}
}
}
}
}
}

class HelloActor extends Actor {
def act() {
while (true) {
receive {
case name: String => println("Hello, " + name)
}
}
}
}

object AuctionService {
def main(args: Array[String]) {
val seller: Actor = new HelloActor
val client: Actor = new HelloActor
val minBid = 10
val closing = new Date()

val helloActor = new HelloActor
helloActor.start()
helloActor ! "leo"
}
}

  通过重写 Actor 中的 act 方法即可简单的实现多线程编程,Actor 中有个特殊的标识符 !,该符号其实是是一种缩写,即可将 helloActor.!("leo") 缩写为 helloActor ! "leo",代表将数据传递给 Actor,由 Actor 内部的 receive case 接受数据并处理,当然也可通过 receiveWithin 控制数据传递时间,若超时,则默认触发 TIMEOUT 处理模式。

第四章:表达式与简单函数

该章主要有两个例子:1、牛顿法求平方根;2、尾递归,具体如下:

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
40
41
42
43
44
45
46
47
object Sqrt {
def sqrt(x: Double): Double = {
def sqrtIter(guess: Double, x: Double): Double = {
if (isGoodEnough(guess, x)) guess
else sqrtIter(improve(guess, x), x)
}

def improve(guess: Double, x: Double) = {
(guess + x / guess) / 2
}

def isGoodEnough(guess: Double, x: Double) = (guess * guess - x).abs < 0.001 // guess * guess == x

sqrtIter(1.0, x)
}
}

object TailRecursion {
def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b)

def facorial(n: Int): Int = if (n == 0) 1 else n * facorial(n-1)

def facorialTail(n: Int): Int = {
def facorialIter(n: Int, res: Int): Int = {
if (n == 0) res
else facorialIter(n-1, res * n)
}

facorialIter(n, 1)
}
}

object SimpleFunc {
def main(args: Array[String]) {
val sqrtValue = Sqrt.sqrt(0.01)
println(sqrtValue)

val gcdValue = TailRecursion.gcd(14,21)
println(gcdValue)

val facorialValue = TailRecursion.facorial(5)
println(facorialValue)

val facorialTailValue = TailRecursion.facorialTail(5)
println(facorialTailValue)
}
}

  由于并没有引入新的语法,就简单聊聊这两个例子吧。牛顿法求平方根主要在于构造一个特殊的二分函数 \(y_{i+1} = (y_i + x / y_i)/2, i=0,1,2,3,..., y_0=1\) ,如此迭代,直到 \(|y_i^2-x| < \epsilon\) ,得到 \(y_i\) 即为 x 的平方根,更朴素一点的求多次方根就是利用二分法,分 [0, 1] 和 [1, +∞] 两个区间即可,对应从 [x, 1] 和 [1, x] 开始二分取值。至于尾递归,以前简单的写过一点,即最后递归调用原函数时,原函数不会再参与任何计算表达式。尾递归的好处在于当编译器或解释器支持尾递归时,将不会产生普通递归时的压栈操作,即不用担心递归层次太深,尾递归将类似循环迭代处理。

第五章:高阶函数

  高阶函数(First-Class Functions),支持以函数作为参数或返回值,也可将函数赋值给其它变量,由此也可引出闭包和柯里化,闭包是指将内嵌函数作为返回值,而柯里化是指将多个参数分解为独立参数传递给函数,如:\(f(args_1,args_2,...,args_n)=f(args_1)(args_2)(...)(args_n)\)。下面以求函数的不动点为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
object FirstClassFunctions {
val tolerance = 0.0001
def isCloseEnough(x: Double, y: Double) = ((x-y) / x).abs < tolerance
def fixedPoint(f: Double => Double)(firstGuess: Double) = {
def iterate(guess: Double): Double = {
val next = f(guess)
if (isCloseEnough(guess, next)) next
else iterate(next)
}
iterate(firstGuess)
}

def averageDamp(f: Double => Double)(x: Double) = (x + f(x)) / 2
def sqrt(x: Double) = fixedPoint(averageDamp(y => x/y))(1.0)

def main(args: Array[String]) {
println(sqrt(0.01));
}
}

  该示例简单明了的展示了 Scala 中匿名函数,函数柯里化以及闭包。

第六章:类和对象

直接看下面的有理数示例吧,

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
// 主构造函数
class Rational(n: Int, d: Int) extends AnyRef {
private def gcd(x: Int, y: Int): Int = {
if (x == 0) y
else if (x < 0) gcd(-x, y)
else if (y < 0) -gcd(x, -y)
else gcd(y % x, x)
}
private val g = gcd(n, d)

// 构造函数重载(辅助构造函数)
def this() {
this(0, 0) // 调用主构造函数
}

val number: Int = if (g != 0) n / g else 0
val denom: Int = if (g != 0) d / g else 0

def +(that: Rational) = new Rational(number * that.denom + that.number * denom, denom * that.denom)
def -(that: Rational) = new Rational(number * that.denom - that.number * denom, denom * that.denom)
def *(that: Rational) = new Rational(number * that.number, denom * that.denom)
def /(that: Rational) = new Rational(number * that.denom, denom * that.number)

def toNumber: Double = if (denom != 0) number.toDouble / denom else 0.0

override def toString = "" + number + "/" + denom
}

object Rational {
def main(args: Array[String]) {
val rational = new Rational(2,1) / new Rational()
println(rational.toNumber);
println(rational.toString);
}
}

  从有理数这个示例可以看出,Scala 的类支持操作符重载,也支持构造函数重载,同样支持继承,多继承也是支持的,每个父类用 with 关键字分隔就行。

第七章:条件类和模式匹配

大致和第一章内容差不多,就不重复写了。

第八章:泛型

  大致也和第一章内容差不多,值得一提的书中实现的泛型栈本质是一个链表,实现方法挺有意思的。通过 <: 标识符可约束泛型的类型,如 [T <: P[T]] 表明泛型 T 必须类型 P 的子类型。而标识符 <%<: 约束性弱一点,只要 T 能够通过隐式类型变换为 P 即可。若想约束为父类型,则需使用 >: 标识符。

  Scala 中有一种特殊的泛型,就是变化型注解,trait List[+T] 代表协变,表示当 B 类型是 A 类型子类时,List[B] 也可认为是 List[A] 的子类;trait List[-T] 代表逆变,当 B 类型是 A 类型子类时,List[B] 可认为是 List[A] 的父类。

  Scala 中同样有元组,使用时也很方便,简单使用直接用括号声明即可,如 def divmod(x: Int, y: Int): (Int, Int) = (x / y, x % y),该函数即返回一个元组,也可声明一个元组 case class Tuple2[A, B](_1: A, _2: B),若需要取元组的元素可通过 _i 的方式,如 val xy = divmod(3, 4); xy._1; xy._2;,也可通过 match-case 语句取,如 xy match { case (n, d) => println("quotient: " + n + ", rest: " + d) }

第九章:List

  Scala 中的 List 其实是数组结构,并且是不可变的,可认为是 C++ 里的静态数组,不能往其中添加或删除元素,下面用数组排序示例下 List 的用法:

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
object Sort {
def insertSort(xsl: List[Int]): List[Int] = {
def insert(x: Int, xs: List[Int]): List[Int] = {
xs match {
// case Nil => List(x)
case List() => List(x)
case y :: ys => if (x <= y) x :: xs else y :: insert(x, ys)
}
}

if (xsl.isEmpty) Nil
else insert(xsl.head, insertSort(xsl.tail))
}

def mergeSort[A](less: (A, A) => Boolean)(xs: List[A]): List[A] = {
def merge(xs1: List[A], xs2: List[A]): List[A] = {
if (xs1.isEmpty) xs2
else if (xs2.isEmpty) xs1
else if (less(xs1.head, xs2.head)) xs1.head :: merge(xs1.tail, xs2)
else xs2.head :: merge(xs1, xs2.tail)
}

val n = xs.length / 2
if (n == 0) xs
else merge(mergeSort(less)(xs take n), mergeSort(less)(xs drop n))
}

def main(args: Array[String]) {
val xs = List(4, 1, 5, 7,7,7,7, 2, 6);
// val xs = 3::2::1::1::Nil;
println(xs(0), xs(1), xs(xs.length-1)) // (4,1,6)
// val ys = insertSort(xs);
val ys = mergeSort((x: Int, y: Int) => x > y)(xs);
println(ys.mkString(" "))
}
}

  List 中有两个操作符非常类似,即 :::::, 前者用于 List 中的元素和 List 连接,即创建一个新 List,新 List 为原 List 头插入元素后的 List,后者用于连接两个 List,即创建一个新 List ,新 List 为将第二个 List 的元素全部放入第一个 List 尾部的 List。字符 Nil 代表空 List 和 List() 等效,head 方法返回 List 的第一个元素,tail 方法返回除第一个元素之外的其它所有元素,还是一个 List,isEmpty 方法当 List 为空时返回 true。List 的 case-match 方法中,case y :: ys 其中 y 代表 xs.head,ys 代表 xs.tail。(xs take n) 表示取 List 前 n 个元素,(xs drop n) 表示取 List 前 n 个元素之外的元素,即与 (xs take n) 取得元素正好互补,而 (xs split n) 返回一个元组,元组中第一个元素为 (xs take n),第二个元素为 (xs drop n)。关于 List 还有些更高阶得方法:filter,map, flatMap, reduceRight, foldRight 等方法就不继续写了。至于动态 List 可用 ListBuffer 结构,当然 Scala 中直接用 Seq 作为返回值和参数一般会更好些。

第十章:序列理解

  Scala 中用来做序列理解的表达式是 For-Comprehensions,具体示例如下:for (p <persons if p.age > 20) yield p.name 相当于 persons filter (p => p.age > 20) map (p => p.name),可以简单认为 for-yield 方法是 filter 和 map 的集合体。下面具体用个 N-皇后(特例是 8 皇后)的示例来具体说明:

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
object NQueen {
def queens(n: Int): List[List[Int]] = {
def isSafe(col: Int, queenList: List[Int], delta: Int): Boolean = {
val curRow = queenList.length-1 + delta
for (row <- List.range(0, queenList.length)) {
val queenCol = queenList(row)
val queenRow = queenList.length-1 - row

if (queenCol == col) return false
if (queenRow == curRow) return false
if ((queenCol - col).abs == (queenRow - curRow).abs) return false
}
true
}

def placeQueens(k: Int): List[List[Int]] = {
if (k == 0) List(List())
else for {
queens <- placeQueens(k-1);
column <- List.range(0, n);
if isSafe(column, queens, 1)
} yield column :: queens
}

placeQueens(n)
}

def main(args: Array[String]) {
val queenList = queens(8);
println("queenCount: " + queenList.length) // 92
}
}

for-yield 表达式中 for 中可以写多条语句,代表多重循环,第 5 行的 for 代表 for 循环,<- 表示取 List 中的元素。


  剩下的几章就没啥特别要写的,重点就两个特性,一个是 Stream ,一个 Lazy,Stream 和 List 有点类似,主要区别在于 Stream 是即时返回的,算一个返回一个,而 List 一般是全部计算完再返回一个 List;Lazy 一般用作常量的修饰符,主要作用是只用该常量被用到时才赋值,否则一直为空,有点类似常见的先判空再取值的封装。

后记

  曾看到过通过刷题去学习新语言的方式,一直都以为很粗暴,但这次照着「Scala By Example」敲下来,感觉还挺有效的,同时也巩固了一下基本的算法知识,后续再把 twitter 的 「Effective Scala」再看一下应该就差不多了。

Linux服务器运维文档

前言

  记录一下服务器问题排查常用的一些命令。

前言

  记录一下服务器问题排查常用的一些命令。

常用篇

Linux

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 只列出含 XXX 的文件
ll | grep "XXX"

# 按列显示文件名
ls -1
ls -l | grep ^[^d] | awk '{print $9}'

# 返回进入当前目录之前的目录
cd -

# 在文件中查找带 XXX 的行,并输出到 /tmp/99
fgrep "XXX" a.txt > /tmp/99

# 在当前文件夹中查找带 XXX 的行,并输出到 /tmp/99
fgrep "XXX" -r ./* > /tmp/99

# 显示前5行
head -n 5 a.txt

# 显示倒数第5行
tail -n 5 a.txt

# 显示第5行至末尾
tail -n +5 a.txt

# 提取第二行 [linux系统中sed命令输出指定的行](https://www.cnblogs.com/superbaby11/p/16556602.html)
sed -n '2p' a.txt

# 以;分隔每一行,并提取第一列和第三列
awk -F ';' '{print $1,$3}' a.txt

# 以:分隔每一行,并提取第一列和第三列
awk -F '[:]' '{print $1,$3}' a.txt

# 查看 8080 端口占用
lsof -i:8080
netstat -tnlp | grep :8080

# 查看系统运行状态
top

# 查看一定时间内进程cpu占用情况
pidstat

# 查看运行进程
ps -ef

# 查看postgres数据库连接状态,并按cpu使用率排序
ps -aux | grep postgres | sort -nrk 3,3

# 查看磁盘占用大小
du -sh *

# 查看磁盘剩余空间
df -h

# 查看程序被 killed 的原因
dmesg | egrep -i -B100 'killed process'

# 查看 url 请求时间
curl -o /dev/null -s -w %{time_namelookup}:%{time_connect}:%{time_starttransfer}:%{time_total} [url]

正则表达式

常用正则:i Hate Regex

1
2
3
4
5
6
7
8
9
10
11
// 匹配 hello 之前的字符
(.+(?=hello))

// 匹配其他数字和英文字母但不匹配结尾的 2
([a-zA-Z_0-9]+[^2])

// 提取包含test以及time后的数字
test[a-zA-Z0-9\-\_\=\|\ ]*time=([\d+])

// 提取中括号里的内容
[\[](.*?)[\]]

工具

  • crontab:设置定时任务工具;
  • Socat:网络工具(透明代理,端口转发,文件传输等),新版瑞士军刀:socat

服务器之间文件传输

参考资料:Linux下的SCP指令详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 本地主机传输文件到远程主机
scp [本地文件路径] [用户名]@[远程主机IP地址]:[目标路径]
# eg:
scp file.txt user@example.com:/home/user/

# 远程主机传输文件到本地主机
scp [用户名]@[远程主机IP地址]:[远程文件路径] [本地目标路径]
# eg:
scp user@example.com:/home/user/file.txt /path/to/local/

# 传输本地主机整个目录到远程主机
scp -r [本地目录路径] [用户名]@[远程主机IP地址]:[目标路径]
# eg:
scp -r directory/ user@example.com:/home/user/

# 若远程主机的SSH服务器端口不是默认的22端口,则需要指定端口号
scp -P [端口号] [本地文件路径] [用户名]@[远程主机IP地址]:[目标路径]

PostgreSQL

编译安装

参考自:【CentOS7】PostgreSQL-10.3的安装

  1. 安装编译工具:

    1
    yum install -y vim lrzsz tree wget gcc gcc-c++ readline-devel zlib-devel
  2. 进入/usr/local/目录下:cd /usr/local

  3. 下载 tar 包:curl -O https://ftp.postgresql.org/pub/source/v16.2/postgresql-16.2.tar.gz

  4. 解压:tar -xzvf postgresql-16.2.tar.gz

  5. 编译安装:

    1
    2
    3
    4
    5
    cd /usr/local/postgresql-16.2
    ./configure --prefix=/usr/local/pgsql-16.2 # /usr/local/pgsql-16.2 为安装目录
    make && make install

    # Two thousand years later,出现「PostgreSQL installation complete.」代表安装成功
  6. 配置系统环境变量:vi /etc/profile

    1
    2
    3
    4
    ...
    # /etc/profile 文件末尾添加
    export PGHOME=/usr/local/pgsql-16.2
    export PATH=$PGHOME/bin:$PATH
  7. 使配置文件立即生效:source /etc/profile

  8. 创建数据库用户:useradd -m -d /home/postgres postgres

  9. 切换到数据库用户:su postgres

  10. 初始化数据库:pg_ctl init -D /home/postgres/db_data

  11. 启动数据库:pg_ctl start -D /home/postgres/db_data

自启动设置

复制 PostgreSQL 自启动文件:cp /usr/local/postgresql-16.2/contrib/start-scripts/linux /etc/init.d/postgresql

修改自启动文件:vi /etc/init.d/postgresql

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#! /bin/sh

# chkconfig: 2345 98 02
# description: PostgreSQL RDBMS

# This is an example of a start/stop script for SysV-style init, such
# as is used on Linux systems. You should edit some of the variables
# and maybe the 'echo' commands.
#
# Place this file at /etc/init.d/postgresql (or
# /etc/rc.d/init.d/postgresql) and make symlinks to
# /etc/rc.d/rc0.d/K02postgresql
# /etc/rc.d/rc1.d/K02postgresql
# /etc/rc.d/rc2.d/K02postgresql
# /etc/rc.d/rc3.d/S98postgresql
# /etc/rc.d/rc4.d/S98postgresql
# /etc/rc.d/rc5.d/S98postgresql
# Or, if you have chkconfig, simply:
# chkconfig --add postgresql
#
# Proper init scripts on Linux systems normally require setting lock
# and pid files under /var/run as well as reacting to network
# settings, so you should treat this with care.

# Original author: Ryan Kirkpatrick <pgsql@rkirkpat.net>

# contrib/start-scripts/linux

## EDIT FROM HERE

###### 上面不改 #####################
# Installation prefix
prefix=/usr/local/pgsql-16.2

# Data directory
PGDATA="/home/postgres/db_data"
###### 下面不改 #####################

# Who to run postgres as, usually "postgres". (NOT "root")
PGUSER=postgres

# Where to keep a log file
PGLOG="$PGDATA/serverlog"

# It's often a good idea to protect the postmaster from being killed by the
# OOM killer (which will tend to preferentially kill the postmaster because
# of the way it accounts for shared memory). To do that, uncomment these
# three lines:
#PG_OOM_ADJUST_FILE=/proc/self/oom_score_adj
#PG_MASTER_OOM_SCORE_ADJ=-1000
#PG_CHILD_OOM_SCORE_ADJ=0
# Older Linux kernels may not have /proc/self/oom_score_adj, but instead
# /proc/self/oom_adj, which works similarly except for having a different
# range of scores. For such a system, uncomment these three lines instead:
#PG_OOM_ADJUST_FILE=/proc/self/oom_adj
#PG_MASTER_OOM_SCORE_ADJ=-17
#PG_CHILD_OOM_SCORE_ADJ=0

## STOP EDITING HERE

# The path that is to be used for the script
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# What to use to start up postgres. (If you want the script to wait
# until the server has started, you could use "pg_ctl start" here.)
DAEMON="$prefix/bin/postgres"

# What to use to shut down postgres
PGCTL="$prefix/bin/pg_ctl"

set -e

# Only start if we can find postgres.
test -x $DAEMON ||
{
echo "$DAEMON not found"
if [ "$1" = "stop" ]
then exit 0
else exit 5
fi
}

# If we want to tell child processes to adjust their OOM scores, set up the
# necessary environment variables. Can't just export them through the "su".
if [ -e "$PG_OOM_ADJUST_FILE" -a -n "$PG_CHILD_OOM_SCORE_ADJ" ]
then
DAEMON_ENV="PG_OOM_ADJUST_FILE=$PG_OOM_ADJUST_FILE PG_OOM_ADJUST_VALUE=$PG_CHILD_OOM_SCORE_ADJ"
fi


# Parse command line parameters.
case $1 in
start)
echo -n "Starting PostgreSQL: "
test -e "$PG_OOM_ADJUST_FILE" && echo "$PG_MASTER_OOM_SCORE_ADJ" > "$PG_OOM_ADJUST_FILE"
su - $PGUSER -c "$DAEMON_ENV $DAEMON -D '$PGDATA' >>$PGLOG 2>&1 &"
echo "ok"
;;
stop)
echo -n "Stopping PostgreSQL: "
su - $PGUSER -c "$PGCTL stop -D '$PGDATA' -s"
echo "ok"
;;
restart)
echo -n "Restarting PostgreSQL: "
su - $PGUSER -c "$PGCTL stop -D '$PGDATA' -s"
test -e "$PG_OOM_ADJUST_FILE" && echo "$PG_MASTER_OOM_SCORE_ADJ" > "$PG_OOM_ADJUST_FILE"
su - $PGUSER -c "$DAEMON_ENV $DAEMON -D '$PGDATA' >>$PGLOG 2>&1 &"
echo "ok"
;;
reload)
echo -n "Reload PostgreSQL: "
su - $PGUSER -c "$PGCTL reload -D '$PGDATA' -s"
echo "ok"
;;
status)
su - $PGUSER -c "$PGCTL status -D '$PGDATA'"
;;
*)
# Print help
echo "Usage: $0 {start|stop|restart|reload|status}" 1>&2
exit 1
;;
esac

exit 0

接下来有两种方式:

一种是直接执行:cd /etc/rc.d/init.d/ && chkconfig --add postgresql

一种是修改 /etc/rc.d/rc.local 文件:vi /etc/rc.d/rc.local

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# THIS FILE IS ADDED FOR COMPATIBILITY PURPOSES
#
# It is highly advisable to create own systemd services or udev rules
# to run scripts during boot instead of using this file.
#
# In contrast to previous versions due to parallel execution during boot
# this script will NOT be run after all other services.
#
# Please note that you must run 'chmod +x /etc/rc.d/rc.local' to ensure
# that this script will be executed during boot.

exec 2> /tmp/rc.local.log # send stderr from rc.local to a log file
exec 1>&2 # send stdout to the same log file
echo "rc.local starting..." # show start of execution
set -x

touch /var/lock/subsys/local

cd /etc/rc.d/init.d/
sudo sh postgresql start & # 以root执行,不然可能会出现权限错误,&表示后台执行

# 脚本执行完后也给个日志
echo "rc.local completed"

添加可执行权限:chmod a+x /etc/rc.d/rc.local,最后查看一下 rc.local 服务是否启动:

1
2
3
4
5
6
7
8
systemctl status rc-local.serives

# 启动命令
systemctl enable rc-local.service
systemctl start rc-local.service

# 查看数据库服务
ps -ef | grep postgres

若要在容器中设置自启动,在没给容器提权的情况下,则需要第三种方式:将 /etc/rc.d/init.d/postgresql 放进 /root/.bashrc 中启动,vi /root/.bashrc

1
2
3
4
5
...
# /root/.bashrc 文件末尾添加
if [ -f /etc/rc.d/init.d/postgresql ]; then
sh /etc/rc.d/init.d/postgresql start > /tmp/postgresql.start.log 2>&1
fi

原理是:docker 容器在启动时,会自动执行 ~/.bashrc 文件,加载环境变量,当有其他命令在该文件时,也会一起执行。

当然,容器中自启动更普遍的方式应该是在镜像/容器中通过 CMD 或者 ENTRYPOINT 直接指定 shell 脚本启动执行。

配置文件设置

1
2
3
4
5
6
7
8
// 设置空闲长事务超时时间
idle_in_transaction_session_timeout
// 如果一个会话等待某个类型的锁的时间超过deadlock_timeout的值,该参数决定是否在数据库日志中记录这个信息。
log_lock_waits
deadlock_timeout

// 事务执行超时时间
statement_timeout

psql

1
2
3
4
5
6
7
8
9
10
nohup psql postgresql://user:password@host:port/dbname -f update.sql > update.sql 2>&1 &  # 刷库命令,update.sql 文件以 begin; 开始,commit; 结束
\q # 退出数据库
\c exampledb # 切换数据库
\l+ # 查看全部数据库
\du+ # 查看全部用户
\d+ # 查看全部表
\dt+ [table_name] # 查看表大小
\dn+ # 查看全部schema
\dp [table_name] # 查看表的权限详情
\x # 竖式显示记录

sql

查看锁等待状态

pg中关于AccessShareLock和ExclusiveLock的问题

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
40
41
42
43
44
45
46
47
48
49
-- 1. 先用一个函数来将锁转换为数字,
create function f_lock_level(i_mode text) returns int as
$$

declare
begin
case i_mode
when 'INVALID' then return 0;
when 'AccessShareLock' then return 1;
when 'RowShareLock' then return 2;
when 'RowExclusiveLock' then return 3;
when 'ShareUpdateExclusiveLock' then return 4;
when 'ShareLock' then return 5;
when 'ShareRowExclusiveLock' then return 6;
when 'ExclusiveLock' then return 7;
when 'AccessExclusiveLock' then return 8;
else return 0;
end case;
end;

$$
language plpgsql strict;

-- 2. 修改查询语句,按锁级别排序:
with t_wait as
(select a.mode,a.locktype,a.database,a.relation,a.page,a.tuple,a.classid,a.objid,a.objsubid,
a.pid,a.virtualtransaction,a.virtualxid,a,transactionid,b.query,b.xact_start,b.query_start,
b.usename,b.datname from pg_locks a,pg_stat_activity b where a.pid=b.pid and not a.granted),
t_run as
(select a.mode,a.locktype,a.database,a.relation,a.page,a.tuple,a.classid,a.objid,a.objsubid,
a.pid,a.virtualtransaction,a.virtualxid,a,transactionid,b.query,b.xact_start,b.query_start,
b.usename,b.datname from pg_locks a,pg_stat_activity b where a.pid=b.pid and a.granted)
select r.locktype,r.mode r_mode,r.usename r_user,r.datname r_db,r.relation::regclass,r.pid r_pid,
r.page r_page,r.tuple r_tuple,r.xact_start r_xact_start,r.query_start r_query_start,
now()-r.query_start r_locktime,r.query r_query,w.mode w_mode,w.pid w_pid,w.page w_page,
w.tuple w_tuple,w.xact_start w_xact_start,w.query_start w_query_start,
now()-w.query_start w_locktime,w.query w_query
from t_wait w,t_run r where
r.locktype is not distinct from w.locktype and
r.database is not distinct from w.database and
r.relation is not distinct from w.relation and
r.page is not distinct from w.page and
r.tuple is not distinct from w.tuple and
r.classid is not distinct from w.classid and
r.objid is not distinct from w.objid and
r.objsubid is not distinct from w.objsubid and
r.transactionid is not distinct from w.transactionid and
r.pid <> w.pid
order by f_lock_level(w.mode)+f_lock_level(r.mode) desc,r.xact_start;

现在可以排在前面的就是锁级别高的等待,优先干掉这个。

-[ RECORD 1 ]-+----------------------------------------------------------

locktype | relation -- 冲突类型

r_mode | ShareUpdateExclusiveLock -- 持锁模式

r_user | postgres -- 持锁用户

r_db | postgres -- 持锁数据库

relation | tbl -- 持锁对象

r_pid | 25656 -- 持锁进程

r_xact_start | 2015-05-10 14:11:16.08318+08 -- 持锁事务开始时间

r_query_start | 2015-05-10 14:11:16.08318+08 -- 持锁SQL开始时间

r_locktime | 00:01:49.460779 -- 持锁时长

r_query | vacuum freeze tbl; -- 持锁SQL,注意不一定是这个SQL带来的锁,也有可能是这个事务在之前执行的SQL加的锁

w_mode | AccessExclusiveLock -- 等待锁模式

w_pid | 26731 -- 等待锁进程

w_xact_start | 2015-05-10 14:11:17.987362+08 -- 等待锁事务开始时间

w_query_start | 2015-05-10 14:11:17.987362+08 -- 等待锁SQL开始时间

w_locktime | 00:01:47.556597 -- 等待锁时长

w_query | truncate tbl; -- 等待锁SQL

-[ RECORD 2 ]-+----------------------------------------------------------

locktype | relation

r_mode | ShareUpdateExclusiveLock

r_user | postgres

r_db | postgres

relation | tbl

r_pid | 25656

r_xact_start | 2015-05-10 14:11:16.08318+08

r_query_start | 2015-05-10 14:11:16.08318+08

r_locktime | 00:01:49.460779

r_query | vacuum freeze tbl;

w_mode | RowExclusiveLock

w_pid | 25582

w_xact_start | 2015-05-10 14:11:22.845+08

w_query_start | 2015-05-10 14:11:22.845+08

w_locktime | 00:01:42.698959

w_query | insert into tbl(crt_time) select now() from generate_series(1,1000); -- 这个SQL其实等待的是truncate tbl的锁;

......

统计数据库表以及索引存储空间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 按从大到小排序输出数据库每个索引大小
select indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) as size from pg_stat_user_indexes where schemaname='public' order by pg_relation_size('public'||'.'||indexrelname) desc;

-- [PostgreSQL中查询 每个表的总大小、索引大小和数据大小,并按总大小降序排序](https://blog.csdn.net/sunny_day_day/article/details/131455635)
SELECT
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size,
pg_size_pretty(pg_total_relation_size(c.oid) - pg_indexes_size(c.oid)) AS data_size,
nspname AS schema_name,
relname AS table_name
FROM
pg_class c
LEFT JOIN
pg_namespace n ON n.oid = c.relnamespace
WHERE
relkind = 'r'
AND nspname NOT LIKE 'pg_%'
AND nspname != 'information_schema'
ORDER BY
pg_total_relation_size(c.oid) DESC;

常用sql语句
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
40
41
42
43
44
45
46
47
-- 查找超过1小时的长事务
select count(*) from pg_stat_activity where state <> 'idle' and (backend_xid is not null or backend_xmin is not null) and now()-xact_start > interval '3600 sec'::interval;

-- 查看处于等待锁状态
select * from pg_locks where not granted;
-- 查看等待锁的关系(表,索引,序列等)
select * from pg_class where oid=[上面查出来的relation];
-- 查看等待锁的数据库
select * from pg_database where oid=[上面查出来的database];
-- 锁表状态
select oid from pg_class where relname='可能锁表了的表';
-- 查询出结果则被锁
select pid from pg_locks where relation='上面查出的oid';

-- 关闭事务并回滚
select pg_cancel_backend(pid);
-- 若无法关闭,则强制杀死进程连接
select pg_terminate_backend(pid);

-- 查看连接信息,重点关注state处于idle in transaction
select * from pg_stat_activity;

-- 替换数据库名称
update pg_database set datname = 'destniationDb' where datname = 'sourceDb';
-- 清除数据库所有连接
SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname='test_db' AND pid<>pg_backend_pid();
-- 复制数据库,需断开sourceDb的全部连接
CREATE DATABASE destniationDb TEMPLATE sourceDb OWNER test_user;

-- 清空表并重置自增序列
truncate table table1,table2 RESTART IDENTITY;

-- 导出数据库中数据,HEADER 可不带
\COPY (select * from table1) TO '/tmp/sql_output.csv' WITH CSV HEADER;

-- 输出删除全部表的sql
\COPY (SELECT 'DROP TABLE IF EXISTS "' || tablename || '" CASCADE;' from pg_tables WHERE schemaname = 'public') TO '/tmp/sql_output.sql';

-- 添加部分索引(满足条件才建立索引), where 和 select 语句的一致
create index [XXX] where [XXX]

-- 查看当前连接事务执行超时时间
show statement_timeout;
-- 设置数据库事务执行超时时间为 60 秒
AlTER DATABASE mydatabse SET statement_timeout='60s';
-- 设置用户事务执行超时时间为 5 分钟
ALTER ROLE guest SET statement_timeout='5min';
子查询优化

PG 的子查询实际有两种,分为子连接(Sublink)和子查询(SubQuery),按子句的位置不同,出现在 from 关键字后的是子查询,出现在 where/on 等约束条件中或投影中的子句是子连接。

子查询:select a.* from table_a a, (select a_id from table_b where id=1) b where b.a_id = a.id;

子连接:select * from table_a where id in(select a_id from table_b where id=1);

在简单的子连接查询下,PG 数据库查询优化器一般会将其转化为内连接的方式:select a.* from table_a a, table_b b where a.id=b.a_id and b.id=1;,正常索引没问题情况下这两种方式都能得一样的结果,最终执行的都是索引内连接结果。但在某些情况下,PG 查询优化器在子连接的 SQL 下,子连接的查询会走索引,而主查询会顺序扫描(Seq Scan),原因是当 table_a 的数据量很大时,索引值又有很多重复的,同时查询优化器也不知道子连接返回的具体数据,这时查询优化器可能会认为顺序扫描更快,从而不走索引,导致耗时增加,所以为减少查询优化器的不确定性,最好是直接使用内连接的方式代替 in 语句。 当然,对于特别复杂的查询业务,还是开启事务,分多次查询,在代码层做一些业务逻辑处理更合适,别让数据库把事情全做了,这也能减轻数据库的压力。 PG 查询计划执行路径可以看看: PostgreSQL 查询语句优化postgresql通过索引优化查询速度操作

tricks

权限配置,PostgreSQL权限管理详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 创建只读组
create role readonly_group;
-- 创建只读用户继承只读组
create user reader with password 'reader' in role readonly_group;
-- 删除用户
drop user reader;
-- 将只读组权限赋给只读用户
grant readonly_group to reader;

-- 读权限
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_group;
GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO readonly_group;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO readonly_group;
-- 写权限
GRANT INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO write_group;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO write_group;

主从&备份

参考资料:postgresql流式复制(Streaming Replication)【PostgreSQL】PostgreSQL复制的监控【PostgreSQL】导出数据库表(或序列)的结构和数据pg_ctlpg_basebackup

1
2
3
4
5
6
7
8
-- 创建流复制备份用户(主从)
create user replicator replication login encrypted password 'replicator'

-- 在主库创建一个物理复制槽(PG9.4引入,一个从库一个复制槽)
select pg_create_physical_replication_slot('phy_repl_slot_1');

-- 查看复制槽状态
select * from pg_replication_slots;

相关命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 冷备数据库(结合刷库命令可恢复数据库),加 -s 参数只导出数据库表结构
nohup pg_dump postgresql://user:password@host:port/dbname -f db_dump.20240107.sql > dump.log 2>&1 &

# 新建一个数据库
pg_ctl init -D /home/postgres/db_data_dir

# 修改配置后重新加载配置
pg_ctl reload -D /home/postgres/db_data_dir
# 或者重启数据库
pg_ctl restart -D /home/postgres/db_data_dir

# 设置数据库默认连接密码
export PGPASSWORD=test_pswd

# 完整复制数据库(作为从库)
nohup pg_basebackup -h localhost -p port -U replicator -D /home/postgres/db_data1_dir -v -P -R -Xs > ./backup.log 2>&1 &

# 从库提升为主库
pg_ctl promote -D /home/postgres/db_data_dir

设置主库:postgresql.conf

1
2
3
4
5
6
7
8
wal_level = hot_standby
# 主备机不同步时,re_wind恢复结点
wal_log_hints = on
# 设置最大流复制数(从库数)
max_wal_senders = 3
wal_keep_segments = 64
# 支持从库读,以及从库再拉从库
hot_standby = on

设置主库:pg_hba.conf

1
2
3
4
5
6
# Allow replication connections from localhost, by a user with the
# replication privilege.
local replication all trust
host replication all 127.0.0.1/32 trust
host replication all ::1/128 trust
host replication all 0.0.0.0/0 md5

设置从库 recovery.conf(自 Postgresql 12 起,recovery.conf 并入 postgresql.conf):

1
2
3
standby_mode          = 'on'
primary_conninfo = 'host=db_addr port=db_port user=replicator password=<password>'
primary_slot_name = 'phy_repl_slot_1'

并发 dump&restore 数据库

  1. 导出数据库全部表结构

    1
    pg_dump -d postgresql://user:pswd@host:port/db_name --schema-only -f db_name_schema.sql
  2. 导出外键约束

    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
    psql -d postgresql://owner_user:pswd@host:port/db_name -t -A -F"," -c "
    SELECT DISTINCT
    'ALTER TABLE ' || quote_ident(nsp.nspname) || '.' || quote_ident(cls.relname) ||
    ' ADD CONSTRAINT ' || quote_ident(con.conname) ||
    ' FOREIGN KEY (' || array_to_string(ARRAY(
    SELECT quote_ident(att.attname)
    FROM pg_attribute att
    WHERE att.attnum = ANY(con.conkey)
    AND att.attrelid = cls.oid), ', ') ||
    ') REFERENCES ' || quote_ident(f_nsp.nspname) || '.' || quote_ident(f_cls.relname) ||
    ' (' || array_to_string(ARRAY(
    SELECT quote_ident(att.attname)
    FROM pg_attribute att
    WHERE att.attnum = ANY(con.confkey)
    AND att.attrelid = f_cls.oid), ', ') ||
    ') ON DELETE ' || CASE con.confdeltype
    WHEN 'a' THEN 'NO ACTION'
    WHEN 'r' THEN 'RESTRICT'
    WHEN 'c' THEN 'CASCADE'
    WHEN 'n' THEN 'SET NULL'
    WHEN 'd' THEN 'SET DEFAULT'
    END ||
    ' ON UPDATE ' || CASE con.confupdtype
    WHEN 'a' THEN 'NO ACTION'
    WHEN 'r' THEN 'RESTRICT'
    WHEN 'c' THEN 'CASCADE'
    WHEN 'n' THEN 'SET NULL'
    WHEN 'd' THEN 'SET DEFAULT'
    END || ';'
    FROM pg_constraint con
    JOIN pg_class cls ON con.conrelid = cls.oid
    JOIN pg_namespace nsp ON cls.relnamespace = nsp.oid
    JOIN pg_class f_cls ON con.confrelid = f_cls.oid
    JOIN pg_namespace f_nsp ON f_cls.relnamespace = f_nsp.oid
    WHERE con.contype = 'f';" > db_name_fkeys.sql
  3. 导出数据库全局用户/权限

    1
    pg_dumpall -d postgresql://superuser:pswd@host:port --globals-only -f db_name_user.sql
  4. 4个并行任务导出全部数据

    1
    pg_dump -d postgresql://user:pswd@host:port/db_name --data-only -F d -j 4 -f ./db_name_data_dir
  5. 新建数据库实例

    1
    pg_ctl init -D ~/new_db_data
  6. 导入数据库全局用户/权限

    1
    psql -U superuser -p port -f db_name_user.sql
  7. 新建数据库

    1
    create database new_db_name owner owner_user
  8. 导入数据库全部表结构

    1
    psql -U superuser -p port -f db_name_schema.sql
  9. 移除新库外键约束

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    psql -d postgresql://owner_user:pswd@host:port/db_name <<EOF
    DO \$\$
    DECLARE
    r RECORD;
    BEGIN
    FOR r IN (SELECT conname, conrelid::regclass
    FROM pg_constraint
    WHERE contype = 'f') LOOP
    EXECUTE 'ALTER TABLE ' || r.conrelid || ' DROP CONSTRAINT ' || r.conname;
    END LOOP;
    END \$\$;
    EOF
  10. 4个并行任务导入数据

    1
    pg_restore -d postgresql://owner_user:pswd@host:port/db_name -j 4 ./db_name_data_dir
  11. 恢复新库外键约束

    1
    psql -d postgresql://owner_user:pswd@host:port/db_name -f db_name_fkeys.sql

MVCC/数据碎片/索引膨胀/FREEZE

参考自:PostgreSQL | 空间又告警了,先从整理索引碎片开始正确的评估postgres index膨胀PostgreSQL VACUUM 之深入浅出 (一)深入理解 PostgreSQL 中的 MVCC(多版本并发控制)机制硬核-深度剖析PostgreSQL数据库“冻结炸弹”原理机制

简单总结一下:

  • mvcc 主要通过锁或乐观并发控制机制来解决冲突,通过事务号实现多版本及查询可见性(当前事务只能看到当前事务启动前已提交的数据,即只可能大事务号看到小事务号的数据),当事务号达到设定值时,事务号会发生回卷,此时需要以单用户模式执行 vacuum freeze 操作,将所有事务号置为2,代表冻结事务,对所有事务可见,当然可通过设置参数实现自动 freeze,减少人工介入维护时间;
  • 由于 postgres 的 mvcc 机制,更新和删除以及新增的回滚都会造成数据碎片,虽然有 vacuum,但索引的膨胀不可避免,所以当发现索引碎片率超过 30% 时,需要进行重建索引 REINDEX,但常规的 REINDEX 会锁表,在 pg12 之后才有 REINDEX CONCURRENTLY,可在线重建,不会锁表,重建完之后需要执行 ANALYZE 更新一下统计信息使索引立即生效。

空间清理

  由于标准的 vacuum 无法释放空间归还给操作系统,只是在数据库内部清理/释放/使用(所以 vacuum 只对于未造成空间膨胀的数据库有效,而且当存在大量更新/删除操作时,vacuum 也不一定能及时控制数据库大小,导致数据库空间一步步变大)。而 VACUUM FULL 或者 CLUSTER 在清理磁盘时会进行锁表(SELECT、INSERT、UPDATE 和 DELETE 等操作都无法正常进行,基本可认为是需要停机维护),对于已经占用大量存储空间的数据库,可以使用 pg_repack 进行在线清理/释放表空间,相比 CLUSTER 或 VACUUM FULL,pg_repack 无需获取排它锁,更轻量。

  对于既成事实占用存储空间超大的数据库,缩减空间一个可能的方案是先 dump 数据,同时开始记录原数据库增量的 dml sql(log_statement=mod),新建一个数据库,用 dump sql 文件写入,记录 dump 最新的节点(时间或者啥 id,再将原数据库节点之外的数据迁移到新数据库中(用之前记录的增量 dml sql,需过滤回滚的事务),再用新数据库替换原数据库,如此达到释放空间的目的(该方案同样适用于数据库版本升级)。


常见问题:

  1. 当自增主键报 duplicate key value violates unique constraint 主键冲突时,一般是因为存在手动分配 id 的数据(复制表或着手动插入分配了 id),自增主键 seqence TABLE_COLUMN_seq 没有更新,新插入一个值自增 id 和数据库已插入的分配 id 冲突,此时需要执行 SELECT setval('TABLE_COLUMN_seq', (SELECT max(COLUMN) FROM "TABLE")) 更新自增主键;

  2. 分析 sql 性能时,可在 sql 语句前增加 EXPLAIN 关键字,查看执行计划,EXPLAIN 一般不会实际执行 sql,但 sql 中带有子语句时,子语句可能会执行,所以为保险起见,最好是在事务中使用 EXPLAIN;eg:

    1
    2
    3
    begin;
    EXPLAIN select * from table1 where id=1;
    rollback;

    若要分析实际执行时间,可以使用 EXPLAIN ANALYZE,该选项会实际执行 SQL,也可以组合参数一起分析执行命令 explain (analyze,verbose,costs,buffers,timing) select * from table1 where id=1;

  3. 如果业务数据无法直接使用批量写入数据库,就最好在一个事务中写入(当然也得看数据量),在同一个事务中写入,不仅能利用事务本身的 ACID 特性,而且比单独分次执行 sql 效率更高;

  4. PG 数据库中,如果要使用 order 排序查询时,一般带主键的复合索引比单个字段索引更有效,因为 PG 数据在数据更新后,一般会乱序存储,导致单字段索引在查询时需要访问的页面会更多;

  5. PG 刚创建/删除索引后,不一定会及时生效,需要数据库运行一段时间后才会开始生效,如需要立即生效,可执行 ANALYZE VERBOSE table_name;命令,离线或者低负载的时候可以执行 VACUUM VERBOSE ANALYZE table_name,清理表的同时更新统计信息,得到更好的 SQL 执行计划。

后记

  后面持续更新。。。

时空查询之ECQL

前言

  ECQL 是 CQL 的扩展,CQL 是 OGC 标准查询语言,而 ECQL 是 GeoTools 为更好的方便查询,在编程实现时扩展了 CQL,主要扩展在于其移除了 CQL 的一些限制(属性必须在比较运算符的左边,不能创建 Id Filter 进行查询等限制),也和 SQL 更相似。所以可简单认为 CQL 是书面上的标准,而 ECQL 是事实上的标准。

前言

  ECQL 是 CQL 的扩展,CQL 是 OGC 标准查询语言,而 ECQL 是 GeoTools 为更好的方便查询,在编程实现时扩展了 CQL,主要扩展在于其移除了 CQL 的一些限制(属性必须在比较运算符的左边,不能创建 Id Filter 进行查询等限制),也和 SQL 更相似。所以可简单认为 CQL 是书面上的标准,而 ECQL 是事实上的标准。

谓词篇

时间查询主要有以下几个查询谓词:

谓词作用
T TEQUALS Time测试 T 和给定时间相等,相当于 T == Time。
T BEFORE Time测试 T 在给定时间之前,相当于 T < Time。
T BEFORE OR DURING Time Period测试 T 在给定时间段之前或其中,相当于 T <= TimePeriod[1]。
T DURING Time Period测试 T 在给定时间段其中,相当于 TimePeriod[0] <= T <= TimePeriod[1]。
T DURING OR AFTER Time Period测试 T 在给定时间段其中或之后,相当于 TimePeriod[0] <= T。
T AFTER Time测试 T 在给定时间之后,相当于 T > Time。

时间段以 / 分隔符区分前后两个时间,时间格式一般为 yyyy-MM-dd'T'HH:mm:ss.SSS'Z'。

空间查询主要有以下几个查询谓词:

谓词作用
INTERSECTS(A: Geometry, B: Geometry)测试 A 与 B 相交,与 DISJOINT 相反。
DISJOINT(A: Geometry, B: Geometry)测试 A 与 B 不相交,与 INTERSECTS 相反。
CONTAINS(A: Geometry, B: Geometry)测试 A 包含 B,与 WITHIN 相反。
WITHIN(A: Geometry, B: Geometry)测试 B 包含 A,即 A 在 B 中,与 CONTAINS 相反。
TOUCHES(A: Geometry, B: Geometry)测试 A 的边界是否与 B 的边界接触,但内部不相交。
CROSSES(A: Geometry, B: Geometry)测试 A 与 B 是否相交,但不存在包含关系。
OVERLAPS(A: Geometry, B: Geometry)测试 A 与 B 是否重叠,需满足 A 与 B 是同一类型(如都是 POLYGON),并且相交区域同样是 A 和 B 的类型(只能是 POLYGON,不能是 POINT)。
EQUALS(A: Geometry, B: Geometry)测试 A 与 B 完全相等。
RELATE(A: Geometry, B: Geometry, nineIntersectionModel: String)测试 A 与 B 是否满足 DE-9IM 模型,该模型可模拟上述所有情况。
DWITHIN(A: Geometry, B: Geometry, distance: double, units: String)测试 A 与 B 的最短距离是否不超过多少距离,单位有(feet, meters, statute miles, nautical miles, kilometers)。
BEYOND(A: Geometry, B: Geometry, distance: Double, units: String)测试 A 与 B 的最短距离是否超过多少距离。
BBOX(A: Geometry, leftBottomLng: Double, leftBottomLat: Double, rightTopLng: Double, rightTopLat: Double, crs="EPSG:4326")测试 A 是否与给定 box 相交。

Geometry 是指 WKT 格式的数据,主要有以下几种:

类型示例
POINTPOINT(6 10)
LINESTRINGLINESTRING(3 4,10 50,20 25)
POLYGONPOLYGON((1 1,5 1,5 5,1 5,1 1),(2 2,2 3,3 3,3 2,2 2))
MULTIPOINTMULTIPOINT(3.5 5.6, 4.8 10.5)
MULTILINESTRINGMULTILINESTRING((3 4,10 50,20 25),(-5 -8,-10 -8,-15 -4))
MULTIPOLYGONMULTIPOLYGON(((1 1,5 1,5 5,1 5,1 1),(2 2,2 3,3 3,3 2,2 2)),((6 3,9 2,9 4,6 3)))
GEOMETRYCOLLECTIONGEOMETRYCOLLECTION(POINT(4 6),LINESTRING(4 6,7 10))

※注: POLYGON 中的边界点必须闭合,即首尾点相同,若存在多个边界,则需要遵循 逆时针,顺时针,逆时针,顺时针... 的点排列顺序,逆时针封闭,顺时针开孔,以形成具有岛和洞的复杂多边形。

  由于 WKT 标准只支持二维的坐标,为支持三维坐标以及齐次线性计算,所以在 PostGIS 中又有 EWKT 标准实现,EWKT 扩展了 WKT,带 Z 结尾用来支持三维坐标,带 M 结尾用来支持齐次线性计算,如 POINTZ(6 10 3)POINTM(6 10 1)POINTZM(6 10 3 1),同时还支持坐标内嵌空间参考系,如 SRID=4326;LINESTRING(-134.921387 58.687767, -135.303391 59.092838)。GeoTools 19.0 之后也默认以 EWKT 进行解析和编码。

查询篇

属性字段查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 查询属性 ATTR1 小于 7 的数据
Filter filter = ECQL.toFilter("ATTR1 < (1 + ((3 / 2) * 4))" );

// 查询属性 ATTR1 小于属性 ATTR2 绝对值的数据
Filter filter = ECQL.toFilter("ATTR1 < abs(ATTR2)" );

// 查询属性 ATTR1 为 test 字符串的数据
Filter filter = ECQL.toFilter("ATTR1 == 'test'" );

// 查询属性 ATTR1 在 10 和 20 之间的数据
Filter filter = ECQL.toFilter( "ATTR1 BETWEEN 10 AND 20" );
Filter filter = ECQL.toFilter( "ATTR1 >= 10 AND ATTR1 <= 20" );

// 多条件查询
Filter filter = ECQL.toFilter("ATTR1 < 10 AND ATTR2 < 2 OR ATTR3 > 10" );

// 查询属性 ATTR1 为 silver 或 oil 或 gold 的数据
Filter filter = ECQL.toFilter("ATTR1 IN ('silver','oil', 'gold' )");

// 以 ID 主键进行查询
Filter filter = ECQL.toFilter("IN ('river.1', 'river.2')");
Filter filter = ECQL.toFilter("IN (300, 301)");

模糊查询

1
2
3
4
5
6
7
8
9
10
11
// 查询属性 ATTR1 包含 abc 字符串的数据
Filter filter = ECQL.toFilter( "ATTR1 LIKE '%abc%'" );

// 查询属性 ATTR1 开头不为 abc 字符串的数据
Filter filter = ECQL.toFilter( "ATTR1 NOT LIKE 'abc%'" );

// 查询属性 cityName 开头为 new 的数据,忽略 new 的大小写
Filter filter = ECQL.toFilter("cityName ILIKE 'new%'");

// 测试字符串是否包含
Filter filter = ECQL.toFilter("'aabbcc' LIKE '%bb%'");

空属性查询

1
2
3
4
5
6
7
8
// 查询有属性 ATTR1 存在的数据
Filter filter = ECQL.toFilter( "ATTR1 EXISTS" );

// 查询属性 ATTR1 不存在的数据
Filter filter = ECQL.toFilter( "ATTR1 DOES-NOT-EXIST" );

// 查询 Name 为 NULL 的数据
Filter filter = ECQL.toFilter("Name IS NULL");

时间查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 查询时间属性 dtg 等于的数据
Filter filter = ECQL.toFilter( "dtg TEQUALS 2006-11-30T01:30:00Z" );

// 查询时间属性 dtg 在之后的数据
Filter filter = ECQL.toFilter("dtg AFTER 2006-11-30T01:30:00Z");

// 查询时间属性 dtg 在之前的数据
Filter filter = ECQL.toFilter("dtg BEFORE 2006-11-30T01:30:00Z");

// 查询时间属性 dtg 在之间的数据,+3:00 代表 GMT 时间 +3 小时,以 Z 结尾的时间就是 GMT 时间
Filter filter = ECQL.toFilter( "dtg DURING 2006-11-30T00:30:00+03:00/2006-11-30T01:30:00+03:00 ");

// 查询时间属性 dtg 等于的数据
Filter filter = ECQL.toFilter("dtg = 1981-06-20");

// 查询时间属性 dtg 小于等于的数据
Filter filter = ECQL.toFilter("dtg <= 1981-06-20T12:30:01Z");

空间查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 查询空间属性 geom 包含点的数据
Filter filter = ECQL.toFilter( "CONTAINS(geom, POINT(1 2))" );

// 查询空间属性 geom 与 box 相交的数据
Filter filter = ECQL.toFilter( "BBOX(geom, 10,20,30,40)" );

// 查询空间属性 geom 与点最短距离不超过 10 千米的数据
Filter filter = ECQL.toFilter( "DWITHIN(geom, POINT(1 2), 10, kilometers)" );

// 查询空间属性 geom 与线相交的数据(geom 也必须是线)
Filter filter = ECQL.toFilter( "CROSS(geom, LINESTRING(1 2, 10 15))" );

// 查询空间属性 geom 与 GEOMETRYCOLLECTION 相交的数据(geom 也必须是 GEOMETRYCOLLECTION)
Filter filter = ECQL.toFilter( "INTERSECT(geom, GEOMETRYCOLLECTION (POINT (10 10),POINT (30 30),LINESTRING (15 15, 20 20)) )" );

// 查询空间属性 geom 与线相交的数据
Filter filter = ECQL.toFilter( "CROSSES(geom, LINESTRING(1 2, 10 15))" );

// 查询空间属性 geom 与 GEOMETRYCOLLECTION 相交的数据
Filter filter = ECQL.toFilter( "INTERSECTS(geom, GEOMETRYCOLLECTION (POINT (10 10),POINT (30 30),LINESTRING (15 15, 20 20)) )" );

// 查询空间属性 geom 与包含线的数据
Filter filter = ECQL.toFilter("RELATE(geom, LINESTRING (-134.921387 58.687767, -135.303391 59.092838), T*****FF*)");

  在 GeoTools 中,可通过 FilterFactory 来构造 Filter,而不是直接写字符串,具体示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();

// 相当于 Filter filter1 = ECQL.toFilter("ATTR1 = 1 AND ATTR2 < 4" );
List<Filter> filterList = ECQL.toFilterList("ATTR1=1; ATTR2<4");
Filter filter1 = ff.and(filterList);

// 相当于 Filter filter2 = ECQL.toFilter( "BBOX(geom, 10,20,30,40)" );
Filter filter2 = ff.bbox("geom", 10, 20, 30, 40, "EPSG:4326");

// 相当于 Filter filter3 = ECQL.toFilter( "dtg DURING 2006-11-29T00:30:00Z/2006-11-30T00:30:00Z");
Date startTime = ZonedDateTime.of(2006, 11, 29, 0, 30, 0, 0, ZoneOffset.UTC);
Date endTime = Date.from(startTime.plusDays(1).toInstant());
Filter filter3 = ff.between(ff.property("dtg"), ff.literal(startTime), ff.literal(endTime));

后记

  基本可认为 CQL 和 SQL 中查询条件差不多,虽然不支持分组查询等复杂 SQL 特性,但对于一般的时空查询基本够用,CQL 中还有些空间操作函数就不继续写了,如取面积,取缓冲区,取交集,取长度等等,有需要的可自行查询 uDig Common Query Language

参考资料

GeoTools CQL

GeoTools ECQL

GeoServer ECQL Reference / GeoServer 属性查询和空间查询支持 CQL / ECQL过滤器语言

WKT解读

GEOS库学习之三:空间关系、DE-9IM和谓词

GeoMesa踩坑指北

前言

  需要做个 GeoMesa 的微服务,简单熟悉一下 GeoMesa。

前言

  需要做个 GeoMesa 的微服务,简单熟悉一下 GeoMesa。

基础篇

  GeoMesa 可以说是大数据中的 PostGIS,主要用来在存储和处理 GIS 数据时提供相应的索引,从而加快处理速度。GeoMesa 基于 GeoTools,其中最重要的两个概念就是 SimpleFeatureType 和 SimpleFeature,SimpleFeatureType 对应的是关系型数据库中表的描述(表明,表的列字段属性信息等),而 SimpleFeature 对应的是表中每行数据。下面重点谈谈 GeoMesa 中的 SimpleFeatureType 以及其创建索引方式。

  在 GeoMesa 中通常使用 SimpleFeatureTypes.createType 方法进行创建,该方法有两个重载,以没有 namespace 参数的方法为例:

1
2
3
4
def createType(typeName: String, spec: String): SimpleFeatureType = {
val (namespace, name) = parseTypeName(typeName)
createType(namespace, name, spec)
}

先通过 parseTypeName 解析 typeName,以 : 作为分隔符,取最后一个有效(不为空)字符串作为表名(name),其余部分如有效则作为 namespace,否则 namespace 则为 null。spec 参数的通用形式有以下几种:

1
2
3
4
5
6
7
val spec = "name:String,dtg:Date,*geom:Point:srid=4326"

val spec = "name:String,dtg:Date,*geom:Point:srid=4326;geomesa.indices.enabled='z2,id,z3'"

val spec = "name:String:index=true,tags:String:json=true,dtg:Date:default=true,*geom:Point:srid=4326;geomesa.indices.enabled='z2,id,z3'"

val spec = "userId:String,trackId:String,altitude:Double,dtg:Date,*geom:Point:srid=4326;geomesa.index.dtg='dtg',geomesa.table.sharing='true',geomesa.indices='z3:4:3,z2:3:3,id:2:3',geomesa.table.sharing.prefix='\\u0001'"

先使用 ; 分隔符,再使用 , 分隔符,最后使用 : 分隔符。; 分隔符将 spec 分割为两个字符串:前者表示表中的全部列属性信息,列属性经过 , 分隔符分割为多列,列又经过 : 分隔符分割为 列名,列数据类型,列的一些属性(是否是索引,json 数据,默认索引等),而列名首字母 * 代表该字段是用于索引的 geometry 类型,一般采用 WKT 格式进行描述,当然存在数据库时会以字节码进行压缩;后者表示创建表时的 userData,同样经过 , 分隔符分割为多个 userData,userData 的一些默认属性可在 SimpleFeatureTypes.Configs 中看到,其它的可以用户自定义,这里重点说一下 geomesa.indices.enabled 属性,目前 GeoMesa 支持 8 种索引,分别为:

1
2
3
4
5
6
7
8
"attr", // 属性索引
"id", // 主键索引
"s2", // Hilbert 曲线点空间索引
"s3", // Hilbert 曲线点时空索引
"z2", // Z 型曲线点空间索引
"xz2", // Z 型曲线线面空间索引
"z3", // Z 型曲线点时空索引
"xz3" // Z 型曲线线面时空索引

  由于 GeoMesa 中的索引一般存在多个版本,而 geomesa.indices.enabled 默认使用最新的版本,若需要指定版本,需要使用 geomesa.indices该属性是 geomesa 内部属性,不对外开放,通用格式为:

1
s"$name:$version:${mode.flag}:${attributes.mkString(":")}"

name 代表索引类别,version 代表索引版本,mode.flag 代表索引模式(是否支持读写,一般为3,支持读也支持写),attributes 代表是哪些字段需要建立该索引。spec 参数可以只有描述列属性的字段,即不带任何 useData 信息,GeoMesa 会默认添加索引信息,若存在空间和时间字段,则会默认建立 z3(空间字段为点 Point 类型) 或 xz3(空间字段为线面 非Point 类型) 索引,若有多个空间和时间字段,建立索引的字段为第一个空间和第一个时间字段;若只存在空间字段,则会建立 z2 或 xz2 索引;若只有时间字段,则默认建立时间属性索引。当然如没有在 spec 指明索引信息,可以在后续继续添加信息,如下:

1
2
3
4
5
6
7
8
9
10
import org.locationtech.geomesa.utils.interop.SimpleFeatureTypes;

String spec = "name:String,dtg:Date,*geom:Point:srid=4326";
SimpleFeatureType sft = SimpleFeatureTypes.createType("mySft", spec);
// enable a default z3 and a default attribute index
sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name");
// or, enable a default z3 and an attribute index with a Z2 secondary index
sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name:geom");
// or, enable a default z3 and an attribute index with a temporal secondary index
sft.getUserData().put("geomesa.indices.enabled", "z3,attr:name:dtg");

坑篇

导入 OSM 数据问题

  在导入 osm 数据时,若使用 osm-ways 作为 SimpleFeatureType,则 geomesa 会使用数据库存储 node 临时使用,这时其默认使用 H2 Database,若想使用其它数据库,则需要在 lib 导入相应 jdbc 包,若使用 postgresql 数据库,则 geomesa 会触发一个 bug,因为 postgresql 没有 double 类型,只有 double precision 类型,这将导致建表出错。详情见 geomesa/geomesa-convert/geomesa-convert-osm/src/main/scala/org/locationtech/geomesa/convert/osm/OsmWaysConverter.scala 中

1
2
3
4
private def createNodesTable(): Unit = {
val sql = "create table nodes(id BIGINT NOT NULL PRIMARY KEY, lon DOUBLE, lat DOUBLE);"
WithClose(connection.prepareStatement(sql))(_.execute())
}

所以若需要使用 geomesa-convert-osm 导入 osm 数据时,需要进入 geomesa/geomesa-convert/geomesa-convert-osm 文件夹中输入命令

1
mvn dependency:copy-dependencies -DoutputDirectory=./depLib

导出 geomesa-convert-osm 依赖包,将其中的 h2,osm4j,dynsax,trove4j 等一系列库放入 $GEOMESA_HBASE_HOME/lib 中。

s2 索引问题

  s2 索引即 Google S2 Geometry 算法基于 Hilbert 曲线生成一种索引,GeoMesa 的 s2 索引是一个国人提交的,目前 3.2 版本只支持点的时空索引,不支持线面的时空索引,当然官方也在实现自己的 Hilbert 曲线,希望后续 GeoMesa 中会有 h2 索引。Shaun 在导入 osm 数据并启用 s2 索引时,报错,被提示不支持,对比 geomesa-index-api2Index.scala 和 geomesa-index-api2Index.scala 两文件的 defaults 函数可发现 S2Index 直接返回空,而在 geomesa-index-api.scala 中 fromName 函数需要调用 defaults 函数,从而导致 s2 索引不支持,修改 S2Index 的 defaults 函数即可(别忘了在 S2Index 类中首行加上 import org.locationtech.geomesa.utils.geotools.RichSimpleFeatureType.RichSimpleFeatureType)。

后记

  暂时就了解了这么多,等后续熟悉的更多再继续更吧 (ง •_•)ง。

附录

GeoMesa 命令行工具部分参数

Geomesa 命令行参数:

参数描述
-c, --catalog *存放 schema 元数据的catalog 表(相当于数据库)
-f, --feature-nameschema 名(相当于数据库中的表)
-s, --spec要创建 SimpleFeatureType 的说明(即表中列的描述信息,表的 schema,如 "name:String,age:Int,dtg:Date,*geom:Point:srid=4326")
-C, --converter指定转换器,必须为一下之一:1、已经在classpath中的converter 名;2、converter 的配置(一个字符串);3、包括converter的配置的名
–converter-error-mode自定义的转换器的error mode
-t, --threads指定并行度
–input-format指定输入源格式(如csv, tsv, avro, shp, json,)
–no-tracking指定提交的 ingest job何时终止(在脚本中常用)
–run-mode指定运行模式,必须为:local(本地)、distributed (分布式)、distributedcombine(分布式组合)之一
–split-max-size在分布式中,指定切片最大大小(字节)
–src-list输入文件为文本文件,按行输入
–force禁用任何的提示
[files]…指定输入的文件

参考资料:GeoMesa命令行工具---摄取命令

IDEA使用Docker环境开发调试

前言

  IDEA 以前基本没用过,只是简单用过 Android Studio,还基本都忘记了 ( ╯□╰ ),以后应该会用 Scala 做一些大数据方面的东西,而大数据的环境都是 Linux 下的,而 Shaun 日常都是在 Windows 下开发,所以需要用日前做的容器环境来测试调试运行程序,简单记录一下 IDEA 在这方面的使用方法。

前言

  IDEA 以前基本没用过,只是简单用过 Android Studio,还基本都忘记了 ( ╯□╰ ),以后应该会用 Scala 做一些大数据方面的东西,而大数据的环境都是 Linux 下的,而 Shaun 日常都是在 Windows 下开发,所以需要用日前做的容器环境来测试调试运行程序,简单记录一下 IDEA 在这方面的使用方法。

运行篇

  右键项目名(HelloWorld),新建文件(New =》File),指定文件名为 Dockerfile 。写入内容示例如下:

1
2
3
4
FROM stc:2.0
COPY ./target/classes/ /tmp
WORKDIR /tmp
ENTRYPOINT ["scala","HelloWorld"]

点击左上角绿色双箭头,可编辑 Dockerfile(Edit 'Dockerfile') ,指定当前上下文目录(Context folder),Contaier name 等容器启动选项。直接运行 Dockerfile(Run 'Dockerfile'),IDEA 即可自动创建容器,并在容器中运行程序,程序运行完则容器自动停止,若需要运行存在外部依赖的程序,则只能以 jar 包的方式运行。

  设置 IDEA 生成 jar 包如下:在最上面的菜单栏中 File =》Project Structure =》Artifacts =》+ =》JAR =》From modules with dependencies,选择 Main Class,点击右边的文件夹图标即可选择相应类,由于存在外部依赖,所以不能直接用默认的 extract to the target JAR,而是应该选择下面的 copy to the output directory and link via manifest,点击 OK 后,自动或手动选择导出的依赖 jar 包,点击 OK。在最上面的菜单栏中 Build =》Build Artifacts...,可在 out/artifacts/HelloWorld_jar 文件夹中生成所有 jar 包。之后编辑 Dockerfile, 更改 Dockerfile 上下文目录为 out/artifacts/HelloWorld_jar ,指定容器名,在 Command 中输入 java -jar HelloWorld.jar 修改 Dockerfile 中第 2 行命令为 COPY . /tmp,修改第 4 行命令为 CMD ["java", "-jar", "HelloWorld.jar"]。之后运行 Dockerfile 即可在下面 Services 栏对应 Docker 容器 Attached Console 中看到程序运行结果。

调试篇

  除了使用 IDEA 生成 jar 包外,还需要使用 IDEA 的远程调试功能,设置 IDEA 远程调试功能如下:在最上面的菜单栏中 Run =》Edit Configurations... =》+ =》Remote JVM Debug,上方的 Debugger mode 中使用默认的 Attach to remote JVM, 在下面的 Before launch 添加 Launch Docker before debug。在弹窗中选择相应 Dockerfile,在下方的 Custom command 中输入 java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -jar HelloWorld.jar, 完成后即可使用该配置在 IDEA 调试容器中运行的程序。

后记

  用这种方式使用 IDEA 确实达到了 Shaun 理想的结果,Windows 下开发,Docker 中调试和运行,应付简单的代码调试和运行确实是没问题,但是在复杂的分布式环境下总会碰到一些莫名奇妙的问题,这些问题就是纯粹的经验了。

参考资料

Run a Java application in a Docker container

Debug a Java application using a Dockerfile

大数据环境搭建笔记

前言

  准备开始搞时空数据了,先简单搭一下环境。

前言

  准备开始搞时空数据了,先简单搭一下环境。

准备搭的环境为:jdk-1.8.0,hadoop-3.2.1,hbase-2.2.6,geomesa-hbase_2.11-3.1.0,spark-3.0.1-bin-hadoop3.2,geoserver-2.16.5-bin,geomesa-hbase_2.11-3.2.0-SNAPSHOT,所用的包都已下好并解压到 /home 目录下。

※注hbase-2.2.6 暂不支持最新的 hadoop-3.3.0,Hadoop 也最好使用 jdk-1.8.0,java-11 会有问题。

Hadoop 环境

  首先修改 /etc/hosts 文件中本机 ip 对应的名称为 master,若在容器中安装则需要在 run 开启容器就指定 --hostname master,否则改了也没用,下次启动容器时 hostname 又会回到初始状态,下面开启正式的配置。

修改 /home/hadoop-3.2.1/etc/hadoop/hadoop-env.sh 文件,添加

1
export JAVA_HOME=$JAVA_HOME

修改 /home/hadoop-3.2.1/etc/hadoop/core-site.xml 文件,添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<configuration>

<!-- master 前面配置的主机名称 -->
<!-- <property>
<name>fs.default.name</name>
<value>hdfs://master:9000</value>
</property> -->

<property>
<name>fs.defaultFS</name>
<value>hdfs://master:9000</value>
</property>

<property>
<name>hadoop.tmp.dir</name>
<value>/home/hadoop/data/tmp</value>
</property>

</configuration>

修改 /home/hadoop-3.2.1/etc/hadoop/hdfs-site.xml 文件,添加

1
2
3
4
5
6
7
8
9
10
11
12
13
<configuration>

<property>
<!--指定SecondaryNameNode位置-->
<name>dfs.namenode.secondary.http-address</name>
<value>master:9001</value>
</property>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>

</configuration>

修改 /home/hadoop-3.2.1/etc/hadoop/yarn-site.xml 文件,添加

1
2
3
4
5
6
7
8
9
10
<configuration>

<!-- Site specific YARN configuration properties -->

<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
</property>

</configuration>

修改 /home/hadoop-3.2.1/etc/hadoop/mapred-site.xml 文件,添加

1
2
3
4
5
6
7
8
<configuration>

<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>

</configuration>

在 /home/hadoop-3.2.1/sbin/start-dfs.sh 和 /home/hadoop-3.2.1/sbin/stop-dfs.sh 文件头添加

1
2
3
4
5
#!/usr/bin/env bash
HDFS_DATANODE_USER=root
HDFS_DATANODE_SECURE_USER=hdfs
HDFS_NAMENODE_USER=root
HDFS_SECONDARYNAMENODE_USER=root

在 /home/hadoop-3.2.1/sbin/start-yarn.sh 和 /home/hadoop-3.2.1/sbin/stop-yarn.sh 文件头添加

1
2
3
4
#!/usr/bin/env bash
YARN_RESOURCEMANAGER_USER=root
HADOOP_SECURE_DN_USER=yarn
YARN_NODEMANAGER_USER=root

设置环境变量,在 /etc/profile 中添加

1
2
3
4
5
#Hadoop Environment Setting
export HADOOP_HOME=/home/hadoop-3.2.1
export JAVA_LIBRARY_PATH=$HADOOP_HOME/lib/native
export LD_LIBRARY_PATH=$JAVA_LIBRARY_PATH
export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin

由于容器中默认为 root 用户,所以在 /root/.bashrc 文件末尾添加 source /etc/profile,以开机启用设置的环境变量。

在启动 Hadoop 之前需要执行 hdfs namenode -format 进行格式化,启动命令为 /home/hadoop-3.2.1/sbin/start-all.sh后续若需要清空并重新设置 Hadoop 时,必须先删除 /home/hadoop/ 目录,再重新进行格式化。

HBase 环境

修改 /home/hbase-2.2.6/conf/hbase-env.sh 文件,添加

1
2
3
export JAVA_HOME=$JAVA_HOME
# 使用自带的ZooKeeper管理
export HBASE_MANAGES_ZK=true

修改 /home/hbase-2.2.6/conf/hbase-site.xml 文件,添加

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<configuration>

<property>
<name>hbase.rootdir</name>
<value>hdfs://master:9000/hbase</value>
</property>
<property>
<name>hbase.dynamic.jars.dir</name>
<value>hdfs://master:9000/hbase/lib</value>
</property>
<property>
<name>hbase.cluster.distributed</name>
<value>true</value>
</property>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
<property>
<name>hbase.master.maxclockskew</name>
<value>180000</value>
<description>Time difference of regionserver from master</description>
</property>
<property>
<name>hbase.zookeeper.quorum</name>
<value>localhost</value>
</property>
<!-- 修改默认8080 端口-->
<property>
<name>hbase.rest.port</name>
<value>8088</value>
</property>

<!-- 2181 默认端口,尽量不要修改,geomesa-hbase 导入数据时默认连接端口为 2181-->
<property>
<name>hbase.zookeeper.property.clientPort</name>
<value>2181</value>
</property>
<property>
<name>hbase.zookeeper.property.dataDir</name>
<value>/home/hbase/data</value>
</property>
<property>
<name>hbase.unsafe.stream.capability.enforce</name>
<value>false</value>
</property>

<!-- geomesa-hbase -->
<property>
<name>hbase.coprocessor.user.region.classes</name>
<value>org.locationtech.geomesa.hbase.server.coprocessor.GeoMesaCoprocessor</value>
</property>

</configuration>

修改 /home/hbase-2.2.6/conf/regionservers 文件,修改为(原来为 localhost)

1
master

设置环境变量,在 /etc/profile 中添加

1
2
3
#HBase Environment Setting
export HBASE_HOME=/home/hbase-2.2.6
export PATH=$PATH:$HBASE_HOME/bin

配置好之后,执行 start-hbase.sh 启动 HBase。

Spark 环境

修改 /home/spark-3.0.1-bin-hadoop3.2/conf/spark-env.sh 文件,在文件末尾添加

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 配置JAVA_HOME,一般来说,不配置也可以,但是可能会出现问题,还是配上吧
export JAVA_HOME=$JAVA_HOME
# 一般来说,spark任务有很大可能性需要去HDFS上读取文件,所以配置上
# 如果说你的spark就读取本地文件,也不需要yarn管理,不用配
export HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoop

# 设置Master的主机名
export SPARK_MASTER_HOST=master
# 提交Application的端口,默认就是这个,万一要改呢,改这里
export SPARK_MASTER_PORT=7077
# 每一个Worker最多可以使用的cpu core的个数,我虚拟机就一个...
# 真实服务器如果有32个,你可以设置为32个
export SPARK_WORKER_CORES=1
# 每一个Worker最多可以使用的内存,我的虚拟机就2g
# 真实服务器如果有128G,你可以设置为100G
export SPARK_WORKER_MEMORY=2g
# master web UI端口默认8080
export SPARK_MASTER_WEBUI_PORT=8090
# worker web UI端口默认8081
export SPARK_WORKER_WEBUI_PORT=8089

复制 /home/spark-3.0.1-bin-hadoop3.2/conf/slaves.template 文件,并重命名为 slaves,将该文件尾修改为

1
2
# 里面的内容原来为localhost,改为master 
master

设置环境变量,在 /etc/profile 中添加

1
2
export SPARK_HOME=/home/spark-3.0.1-bin-hadoop3.2
export PATH=$PATH:$SPARK_HOME/bin:$SPARK_HOME/sbin

将 /home/spark-3.0.1-bin-hadoop3.2/sbin/start-all.sh 重命名为 start-spark-all.sh,将 /home/spark-3.0.1-bin-hadoop3.2/sbin/stop-all.sh 重命名为 stop-spark-all.sh,执行 start-spark-all.sh 启动 Spark。

geomesa-hbase 环境

编译 geomesa

克隆 LocationTech GeoMesa修改 pom.xml,即修改对应依赖的 hadoop 和 hbase 以及 spark 版本(spark 最新的3.0.1版本由 Scala-2.12 编译,而 Geomesa 编译目前采用 Scala-2.11, 所以 Spark 不能使用最新的版本,只能用 2.4.7)。进入 geomesa 根目录,使用命令

1
2
3
4
mvn clean install -DskipTests

# 或仅编译 geomesa-hbase
mvn clean install -pl geomesa-hbase -am -DskipTests

编译 geomesa,中间可能会失败很多次,包下不来,可能需要挂代理或换源,重复使用命令多次即可。

配置 geomesa-hbase

将 /home/geomesa/geomesa-hbase/geomesa-hbase-dist/target/geomesa-hbase_2.11-3.2.0-SNAPSHOT-bin.tar.gz 解压为 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT,将 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT/dist/hbase/geomesa-hbase-distributed-runtime-hbase2_2.11-3.2.0-SNAPSHOT.jar 复制到 /home/hbase-2.2.6/lib/ 文件夹中,修改 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT/conf/dependencies.sh 文件,设置正确的Hadoop 和 hbase 版本,依次执行 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT/bin/install-dependencies.sh 和 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT/bin/install-shapefile-support.sh。设置环境变量,在 /etc/profile 中添加

1
2
3
4
5
export GEOMESA_HBASE_HOME=/home/geomesa-hbase_2.11-3.2.0-SNAPSHOT
export GEOMESA_LIB=$GEOMESA_HBASE_HOME/lib
export GEOMESA_CONF_DIR=${GEOMESA_HBASE_HOME}/conf
export CLASSPATH=$CLASSPATH:$GEOMESA_LIB:$GEOMESA_CONF_DIR
export PATH=$PATH:$GEOMESA_HBASE_HOME/bin

测试 geomesa-hbase

启动 Hadoop 和 HBase 之后,可直接使用命令

1
geomesa-hbase ingest --catalog TestGeomesa --feature-name road --input-format shp "/home/shpdata/road.shp"

导入 shp 数据,shp 不能有 id 字段,因为 Geomesa 在创建表时会默认生成一个 id 字段。

也可克隆 geomesa-tutorials ,同样修改其中的 pom.xml 文件,进入 geomesa-tutorials 根目录,使用命令

1
mvn clean install -pl geomesa-tutorials-hbase/geomesa-tutorials-hbase-quickstart -am

编译 geomesa-tutorials,编译完成后,使用命令

1
java -cp geomesa-tutorials-hbase/geomesa-tutorials-hbase-quickstart/target/geomesa-tutorials-hbase-quickstart-3.2.0-SNAPSHOT.jar org.geomesa.example.hbase.HBaseQuickStart --hbase.zookeepers localhost --hbase.catalog geomesaTest

导入数据进 Hbase,导入成功后可通过 hbase shell 进入 hbase,在 hbase shell 中通过 list 查看 hbase 现有的表。

整合 geoserver

导入依赖插件

1
manage-geoserver-plugins.sh -l ${GEOSERVER_HOME}/webapps/geoserver/WEB-INF/lib/ -i

修改 /home/geomesa-hbase_2.11-3.2.0-SNAPSHOT/bin/install-dependencies.sh 中第33行:

1
2
# install_dir="${GEOMESA_HBASE_HOME}/lib"
install_dir="${GEOSERVER_HOME}/webapps/geoserver/WEB-INF/classes"

执行 install-dependencies.sh 安装插件,安装完后将 classes 中的 lib 都移到 ${GEOSERVER_HOME}/webapps/geoserver/WEB-INF/lib中。

后记

  环境搞起来真麻烦,在编译和运行 Geomesa 时总能遇到一些莫名奇妙的问题,Java 系的这一套确实很麻烦,尤其是各种依赖关系,不过最后总算是搞好了,能直接在 geoserver 中看到 geomesa 存在 hbase 里的地图。

参考资料

Centos7系统 Hadoop+HBase+Spark环境搭建

GeoMesa-HBase操作篇——安装

centos7安装geomesa2.0.2_hbase_geoserver2.13.2的方法

hadoop fs 命令使用

GeoMesa HBase Quick Start

Installing GeoMesa HBase

Spark完全分布式集群搭建【Spark2.4.4+Hadoop3.2.1】

附录

最后附上一些常用的端口及说明:

Hbase

配置端口说明
hbase.master.port16000HMaster绑定端口
hbase.master.info.port16010HBase Master的Web UI端口
hbase.regionserver.port16020HBase RegionServer绑定的端口
hbase.regionserver.info.port16030HBase RegionServer的Web UI端口
hbase.zookeeper.property.clientPort2181Zookeeper客户端连接端口
hbase.zookeeper.peerport2888Zookeeper节点内部之间通信的端口
hbase.zookeeper.leaderport3888Zookeeper用来选举主节点的端口
hbase.rest.port8080HBase REST server的端口
hbase.master.port60000HMaster的RPC端口
hbase.master.info.port60010HMaster的http端口
hbase.regionserver.port60020HRegionServer的RPC端口
hbase.regionserver.info.port60030HRegionServer的http端口

Hadoop

配置端口说明
fs.defaultFS9000hdfs访问端口
dfs.namenode.rpc-address9001DataNode会连接这个端口
dfs.datanode.address9866DataNode的数据传输端口
dfs.namenode.http-address9870namenode的web UI 端口
yarn.resourcemanager.webapp.address8088YARN的http端口

Spark

端口说明
8080master的webUI,Tomcat的端口号(已修改为8090)
8081worker的webUI的端口号(已修改为8089)
18080historyServer的webUI的端口号

需开放端口 22,2181,5432,8080,8088,8089,8090,9870,16010,16030。

docker run -dit --privileged=true --name STC2 --hostname master -v E:/Docker/ShareFile:/mnt/sharefile -p 22:22 -p 80:80 -p 2181:2181 -p 5432:5432 -p 8080-8090:8080-8090 -p 9870:9870 -p 16010:16010 -p 16030:16030 stc:2.0 init

设计模式浅谈

前言

  进入职场一年半以来,Shaun 完全独立从 0 到 1 开发了 1.5 个项目(当然也有参与其它项目,但不是 Shaun 独立从 0 到 1 开发的,没多少控制权,就不谈了),一个网页版的高精地图编辑器,半个地图可视化系统,这里面 Shaun 用了不少设计模式,这篇就谈谈 Shaun 用过的和没用过的一些设计模式。

前言

  进入职场一年半以来,Shaun 完全独立从 0 到 1 开发了 1.5 个项目(当然也有参与其它项目,但不是 Shaun 独立从 0 到 1 开发的,没多少控制权,就不谈了),一个网页版的高精地图编辑器,半个地图可视化系统,这里面 Shaun 用了不少设计模式,这篇就谈谈 Shaun 用过的和没用过的一些设计模式。

  以「Head First 设计模式」为参考,Shaun 用 C++ 实现了一遍书中的例子(代理模式及其后面的模式除外),下面进入正文。

模式篇

策略模式

  Shaun 个人认为最能体现面向对象编程思想(抽象封装继承多态)的一种模式,换句话说,只要真正理解和运用面向对象编程,一定会自然而然的用到策略模式。Shaun 在做高精地图编辑器时,需要设计一个渲染模块,渲染模块会包含高亮行为,高亮有两种,一种是直接改变颜色,一种是使用后期处理(OutlinePass 或 UnrealBloomPass 等)进行高亮,这时就需要在渲染类中组合高亮行为。

  策略模式中涉及到的原则有:1、封装变化;2、多用组合,少用继承;3、针对接口编程,不针对实现编程。封装变化这点很考验程序员的经验水平,在写代码之初,往往预料不到变化,所以这一点一般是在编码过程中逐渐完善的,不断进行抽象,从而生成比较合理的基类;第二点一般也是对的,但有时在编码过程中难免会碰到到底是用继承还是组合的问题,这时候可以多想想,组合并不是万能的,有时继承更合适,这时可以请教身边更有经验的程序员,组合的优势在于当子类不想要这个对象时,可以随时丢弃,而继承的优势在于,当子类不想实现这个行为时,可以有默认的行为,而且有些时候只能用继承;针对接口编程没啥好说的,就是抽象。

观察者模式

  这个模式如果在分布式系统中又叫发布订阅模式,该模式常用于消息通知。前端有个 RxJS 的库将这一模式玩出花来了,Shaun 在高精地图编辑器的事件流管理中就使用了该库。在 threejs 中所有渲染对象的都有一个统一的基类 EventDispatcher,该类中就实现了一个简单的观察者模式,用来处理事件监听触发和通知,和真正的观察者相比,区别在于观察者只是一个函数,而不是一个类,其实浏览器的事件监听机制实现方式也和这个类差不多。

  观察者模式中涉及到原则有:松耦合。这里的松耦合是指主题和观察者之间是隔离的,观察者可自行实现自己的更新行为,而主题同样可实现自己的通知机制,两者虽有关联但互不影响。松耦合原则说起来人人会说,但真正能实现松耦合的却不多,实现松耦合的关键在于怎样分离两个系统,两个系统的连接点在哪,这有时很难理清,从而造成逻辑混乱,bug 丛生。

装饰者模式

  利用该模式可以很方便的扩展一些方法和属性,尤其是遇到主体和配件这样的关系时,可以很轻松的添加配件到主体上。Shaun 没用过这个模式,本来在扩展 threejs 一个类时想用,但确实没找到非常明确的主体和配件这样的关系,最后还是简单的使用继承了。

  装饰者模式涉及到的原则有:开放——封闭原则。设计一个类需要考虑对扩展开放,对修改关闭。修改和扩展看似矛盾,但实则可以独立存在,装饰者的配件可以无限加,这是扩展,是开放,而在加配件时无需修改现有代码,这是封闭。当然这一原则并不独属于装饰者模式,应该说是只要用到面向对象的思想开发程序,就一定会用到该原则,否则也就失去了面向对象的意义。但有时这个原则又没必要贯彻彻底,因为对于有些需求可能很难弄清修改和扩展的界限,这时就达到能尽量重用父类的方法就好。

工厂模式

  该模式在稍微大一点的系统中应该都会用到,根据不同的输入,生成不同的对象,这就是工厂模式的本质。至于工厂模式的实现方式一般会根据需求的复杂度来决定:1、只有一个工厂,一类产品,只是为了集中一层 if-else,可用简单工厂模式,甚至一个 builder 函数即可;2、有多个工厂,还是只有一类产品,用工厂模式,多个工厂继承一个工厂父类即可,相当于多个简单工厂组合;3、有多个工厂,多类产品,哪个工厂生产什么产品可能有变化,这时需要用到抽象工厂模式,除正常的继承之外,还需使用组合,组合组成产品的父类,相当于再组合一个工厂。Shaun 在高精地图编辑器中当然是大量使用的工厂模式和简单工厂模式,主要是为了集中 if-else 的处理,比如根据不同的数据类型创建不同的属性栏界面(枚举用下拉框,字符串用文本框,数字用数字栏等),根据不同的路网元素创建对应的渲染器对象以及对应的属性界面等。

  工厂模式涉及到的原则有:依赖倒置原则。尽量依赖抽象,而不是具体类。这其实也是抽象一种作用或好处,即在使用过程中尽量使用最上层的父类,具体类只在创建实例时使用。

单例模式

  写程序的基本都会用到该模式,主要用来创建全局唯一对象,可用来存储和处理系统中常用的各个模块都会用到的一些数据。Shaun 在编辑器中单例模式用了好几个,比如全局唯一的 viewport,用力绘制 3d 图形;全局唯一的路网数据;当然系统中存在太多的单例模式也不好,最好是只有一个,如 Shaun 的编辑器中最好的模式就是创建一个单例的 Editor 类,需要做单例的对象都可以放在该类中,如此保证系统中只有一个单例类,以进行统一管理。

  该模式与面向对象倒是没多大关系了,可以认为是全局变量的优化版,毕竟大的系统中全局变量基本不可避免,这时就可以使用单例模式。

命令模式

  该模式主要用来将函数方法封装成类,这样做的好处就是可以更灵活的执行该方法(将方法放进队列中依次执行,将方法持久化以便系统启动执行),同时也可以保存该方法的一些中间状态,以便撤销操作,回到系统执行该方法前的状态。Shaun 在编辑器中主要用命令模式做撤销重做功能,这基本也是编辑器的必备功能了,可以说没有撤销重做功能的编辑器是不完整的,要实现撤销重做功能除了基本的命令模式之外,还要提供撤销和重做两个栈以保存操作命令。

  该模式与面向对象也没很大关系,只是提供了一个实现一些特殊功能的标准或通用方案。

适配器模式

  该模式正如其名,主要用来转换接口,将一个类的方法用其它类封装一下,以达到兼容其它类接口的目的,同时对外可接口保持不变,该模式通过对象组合实现。Shaun 没使用过该模式,就 Shaun 感觉这个模式应该可以用在维护了好几年的系统上,当新作的东西需要兼容老接口时,可以用适配器模式将新街口封装一下。

  该模式同样只是提供了一种新接口兼容老接口的一种优良方案,当然实际使用过程中可能很难这么完美,但算是一种思路。

外观模式

  该模式算是封装的一种体现。当一个功能需要经过多次函数调用才能完成时,这时可以用另一个方法将这些函数都封装起来,从而简化调用方式。Shaun 用该模式处理整个渲染模块的初始化和资源释放,因为初始化时需要分配好很多东西(光照,viewport,固定图层,地面,天空盒等),而释放时同样需要释放这些东西。该模式同样只能算是提供了一种好的编程实践,实际使用过程可能每个函数都有很多参数,调用顺序可能有变,这时简化调用反而没有必要,让用户自己决定怎样调用更好。

  外观模式涉及到的原则有:最少知识原则。该原则主要用来减少对象依赖,即尽量不将类中组合的对象直接暴露出去,而应该将组合对象的方法再简单封装一下,再将封装后的方法暴露出去,以减少另外的类又依赖类中组合对象的现象。该原则可以适当遵守,因为有时直接使用更方便一点,多次封装之后反而显得逻辑混乱,增加系统的复杂度。

模板方法模式

  该模式是抽象的一种体现。首先抽象出一套固定化的流程,流程中每个步骤的具体行为并一致,有些默认,有些可以重写,父类固定流程,子类负责重写流程中每个步骤,这就时模板方法模式。Shaun 没写过完全符合该模式的代码,只是写了个类似该模式的模块,该模块有三个功能(编辑道路节点,编辑车道节点,编辑车道线),做完前两个功能后,发现这里有一套逻辑是通用的,那就是滑过节点高亮,选择节点,出现 gizmo,拖动 gizmo,完成编辑(当然还有选择节点后不拖动 gizmo 等一套 if-else 中间处理状态),于是 Shaun 把这一套流程抽象出来,固化方法,这三个功能都继承该类,方法该重写的重写,不仅减少了代码量,同时整个流程也更清晰了,很快完成了第三个功能。

  模板方法涉及到的原则有:好莱坞原则。即由父类负责函数调用,而子类负责重写被调用的函数,不用管父类的调用逻辑,也最好不要调用父类的函数。该原则用来理清流程很方便,只需要看父类即可,但实际编程过程中可能也会遇到子类不可避免的会调用父类的一些公共函数的情况,Shaun 觉得只要流程没问题的话,调用父类函数也能接受,并不需要严格遵守模式。

迭代器模式

  迭代器,即对遍历进行封装,一般只能顺序执行,提供 next() 方法获取下一个元素,集合容器的遍历方式一般都会用迭代器进行封装。Shaun 在这一个半项目里没写过迭代器,毕竟这是非常底层的一个模式,语言库本身有的数据结构大多自己实现了迭代器,除非需要设计一个新的集合或容器数据结构,才需要提供相应的迭代器。因为 js 没有 SortedMap 数据结构,为了高效分配路网元素 id,Shaun 利用 object 简单实现了一个,提供了相应的 forEach 方法。

  迭代器模式涉及到的原则有:单一责任原则。即一类只做一件事,这个原则对于涉及最最底层的接口很实用,而大多具体类很难只做一件事。迭代器模式对于顺序访问来说还是非常有用的,毕竟使用迭代器的人不需要管底层到底用的什么数据结构,反正可以顺序遍历即可。

组合模式

  组合模式与其说是一种模式,更不如说就是一颗树,只是树的节点都是可供继承的类。在标准的组合模式中,父类中一定会有全部子类的全部函数,即所有子类的函数要么是继承自父类,要么是重写父类函数的,这其实是违背上面单一责任原则的,因为这必然会造成有些子类不需要这么多函数。而从组合模式会存储孩子节点这点来看,和装饰者模式有点类似,只不过装饰者只会存一个孩子,而组合模式可能会存多个,当然两者做的事是不一样,只是实现手法类似而已。Shaun 没写过标准的组合模式,如果只要符合树形模式都可认为是组合模式,那在高精地图编辑器中,所有路网元素都会继承一个父类,而道路中又包含车道簇,车道簇中包含车道,这也算组合模式。在 threejs 中有个 Object3D 的基类,所有渲染对象都会继承该类,该类中又包含若干孩子,threejs 计算 Model 矩阵时就是一层层孩子的 Model 矩阵乘下去,直到最后的孩子,结果就是最后 Shader 中的 Model 矩阵。

状态模式

  状态机的状态转移可以说是程序设计中最麻烦的部分之一了,这部分如果写不好的话,根本没法修改维护,同时会造成 bug 频发。在高精地图编辑器中鼠标操作有两类模式,一种是选择模式,另一种是编辑模式,选择模式又分为点选和框选,而编辑模式就非常多了,针对路网的不同元素,编辑模式的具体实现都不会一样,Shaun 首先使用 RxJS 封装了一个鼠标操作类(左键右键中键移动等),后续的鼠标操作都继承自该类,可以算是状态模式的父类,后续的鼠标操作就针对相应的需求实现相应的方法即可,当然其中鼠标操作自身也存在状态转移(左键到右键,左键到鼠标移动等),这些一般都是针对特定需求来的,所以这些小的状态转移一般在鼠标操作内部实现,但需要支持随时从编辑模式到选择模式,这意味着编辑模式编辑到一半的东西都需要支持随时释放,恢复编辑前的样子,这算是一个麻烦的地方,有时忘了释放就会出现问题。

  状态模式算是为解决状态转移问题提供一种理想的方案,但其具体实现并不一定要和书上一样,Shaun 在用 C++ 实现时就采用另一套方案,状态类是独立的,控制状态转移的代码都在状态机内,而不是书中这种直接在状态类中控制状态机。好处坏处都有,看具体需求,Shaun 的方式就是状态类和状态机是分离的,状态类不需要管状态机怎么实现的,只需要管当前状态的情况,但需要在状态机中管理状态转移,而书中实现方式状态机的状态转移放到状态类中了,也因此状态类需要依赖状态机。


剩下的模式,Shaun 就没直接写代码实践了,因为大多都需要跨模块实现,有的甚至就是个小项目了,所以就简要谈谈 Shaun 的个人理解

代理模式

  主要可以用来做权限控制,在模块与模块之间的调用过程中,有时不想要一个模块可以访问另一个模块的全部内容,这时可以使用代理模式,创建一个中间模块,避免两个模块直接调用,同时进行访问控制。代理模式在如今的互联网时代不可避免的会用到,或直接或间接,往最小的说,对象组合也可用来实现代理模式。

复合模式

  将多种模式组合在一起使用,比如 MVC 模式,这种模式与其说是模式,更不如说就是一种架构,一种开发包含客户端系统的通用架构,当然每一层都会有很多模式进行组合,从而造成具体实现差异非常大。

反模式

  反模式指的是用“不好的解决方案”去解决一个问题,Shaun 主要想谈谈开发反模式,因为这非常常见。有时候一个解决方案好不好要从多个角度进行衡量,比如现有技术,长期短期,上手难度,开发效率,维护难度等角度,当出现一个新问题时,往往意味着就有解决方案有缺陷,这种缺陷可能很容易弥补,更可能很难,当很难解决时,往往要采用全新的解决方案,这时团队对新解决方案可能都不熟,也没有魄力去采用新解决方案,只能去老解决方案继续强行打补丁,直到最后没法维护,白白浪费了大量的人力和时间,这是非常典型的一种反模式。

桥接模式

  将抽象接口和实现分开,并独立派生,以支持抽象和实现的同时改变,并相互独立,可适用在需要跨平台跨不同设备的系统上。

生成器模式

  有点像是模板方法模式和工厂模式的结合版,使用多个步骤创建一个对象,但步骤没有固定顺序,可适用于流程复杂的规划系统中。

责任链模式

  可以认为是模板方法模式的进阶版,只是模板的步骤方法变成了一个个对象,并且支持步骤的增加和删除,以及更换顺序,一旦某个步骤成功执行,则整个链条终止,可适用于消除显式的 if-else,处理键盘或鼠标事件时,需要针对不同按键触发不同操作,这时可以采用该模式,缺点是链条很长时,要写很多类,导致执行啥很不直观。

蝇量模式

  这个模式算是一种优化内存占用的方案,通过牺牲类的独立性来减少内存,更彻底一点就是不创建类,直接用函数调用来处理就行。

解释器模式

  可用来实现简单语法规则语言的解释器或编译器,每个语法规则都由一个类进行解析,然后组合。

中介者模式

  可认为是状态模式和代理模式的结合版,不过各个状态可以是不同类,由中介者控制系统流转,集中控制逻辑,使被控制对象解耦,但可能会造成中介者本身非常复杂。

备忘录模式

  可用于系统(游戏)存档,存储系统中关键对象的运行状态,通常实现的方案一般是序列化/持久化,为了效率考虑,难的是有时需要增量存档。

原型模式

  js 的原型链应该是原型模式的典型,不仅实现了动态扩展实例,更实现了动态扩展对象,即继承。在高精地图编辑器中,由于需要做自动保存,所以在做序列化和反序列化的同时也简单实现了对象的 clone(),即从当前实例中创建一个完全一样的实例,可认为是 C++ 中的深拷贝。

访问者模式

  相当于加个中间层,从而以最小的代价修改现有系统(一般是增加一个方法),达到外部可以取得系统内部信息的目的。

后记

  曾看过这样一句话:抽象能力决定编程能力,Shaun 个人认为,所谓抽象即提炼事物的共同点,这也是设计模式中反复使用接口的原因,接口即一组具体类的共同点,接口中的函数和变量即为这些具体类共有的,虽然具体行为可以不一样,但行为本身总是存在的。而又有这样一句话:程序等于数据结构加算法,Shaun 的理解是,狭义上的程序确实是这样,一段代码解决一个问题,这是程序,多段代码解决一个问题,这也是程序,多段代码解决多个问题,这亦是程序,一个软件,一个系统,一个平台,都是程序,但显然这些程序不是简单的数据结构和算法就能概括的,其内部必然有一套合理的逻辑进行组织,这套逻辑可以称之为“设计模式”,但这个“设计模式”不仅仅是上面谈的这些模式概念。Shaun 认为好的数据结构和算法确实能使程序达到当前最优,但对于一个大型系统或平台来说,这种最优只能是一种局部最优,这对整个系统的全局最优来说可能是微不足道的,而“设计模式”要解决的是怎样使一个系统达到全局最优,怎么合理组织局部最优。面对现代的超大型系统或平台,传统意义上的设计模式也只能达到局部最优,全局最优基本很少有人能驾驭,都是针对特定的业务需要,不断的试错改进优化,逐渐趋于稳定,但这种稳定可能很难抽象,放进其它的业务中,又得花费大量的人力物力去修改。

  Shaun 个人对现代大型系统架构的理解就是分层分模块,功能太多分模块,模块太多就分层,一层不够分两层,两层不够分三层,三层不够继续分,根据数据流的处理分层,根据功能的不同分模块,模块内部依靠设计模式进行组织,设计模式调度的就是数据结构与算法。Shaun 目前的设计原则就是:每层一个独立的服务控制模块,每个模块一个对外服务功能(或事件或 socket ),同层的各模块之间尽量保持独立,不相互依赖,若各模块存在共同点,则将共同点抽出来,将其作为公共模块或独立为小层,层与层之间通过服务控制模块进行数据流的传输,除服务控制模块之外,模块之间尽量避免相互通信,即每个模块的对外服务功能一般只对本层服务控制模块提供服务,最好是只负责接收数据。如果系统实在太大,就只能保持纵向分层,横向保证各模块间数据流依次传输,并在特定模块节点上进行上下层的数据传输。

  数据结构与算法重不重要?当然重要,数据结构与算法不过关,面试都过不去 ( ╯□╰ ),工作都没有,还何谈什么设计模式,什么架构。设计模式重不重要?当然也重要,不会合理使用设计模式,写出一堆别人没法维护的垃圾代码(当然,这或许是好事 :p ),改个需求要半天,加个功能要半个月,效率太低,这样即使有好的数据结构与算法作用也不大。但是设计模式也不是万能的,针对不同的需求,同一种设计模式有不同的实现方式,所以书中的设计模式也仅供参考而已,与其说设计模式重要,还不如说书中那几个设计原则更重要些。同时一味的追求设计模式也不见得是件好事,设计模式可以参考,但不能生搬硬套,毕竟人是活的,需求也是活的,固定的模式也需要有所改变,总而言之,能以最小的代价解决问题完成需求的模式就是好模式。

积分计算

前言

  最近需要计算一下曲线长度,无法直接得到被积函数的原函数,常规的积分解法牛顿莱布尼茨公式没法使用,所以只能使用数值积分计算积分。

前言

  最近需要计算一下曲线长度,无法直接得到被积函数的原函数,常规的积分解法牛顿莱布尼茨公式没法使用,所以只能使用数值积分计算积分。

  下面主要介绍两种数值积分方法:龙贝格积分(Romberg Quadrature) 和 高斯-克朗罗德积分(Gauss-kronrod Quadrature)。 下面附带的代码只做简单测试,不保证正确性,语言使用 Typescript。

Romberg 篇

  计算积分最简单的当然是使用复化梯形公式,即 \(I=\int_a^b{f(x)dx}=\frac{b-a}{2n}[f(a)+f(b)+2\sum\limits_{i=1}^{n-1}f(x_i)]= T_n, x_i=a+i*h, h=(b-a)/n\) ,若将 n 段每段一分为 2,可得到 \(T_{2n}=T_n/2+\frac{b-a}{2n}\sum\limits_{i=0}^{n-1}f(x_{i+1/2})\) 。考虑数列 \(T=\{T_1,T_2,T_{2^2},...,T_{2^k}\}\),显然该数列必收敛,最后收敛为对应积分,当 \(|T_{2^k}-T_{2^{k-1}}| < ε\)\(ε\) 为精度)时,可取 \(T_{2^k}\) 作为最后的积分结果。但是,直接利用梯形公司求解,收敛很慢,导致计算效率很差,所以需要使用理查德森(Richardson)外推法加速收敛,设 \(T_{2^k}^{(m)}\) 为 m 次加速值,当 m 为 0 时,表示没加速,为原梯形公司,则 \(T_{2^k}^{(m)} = \frac{4^m}{4^m-1}T_{2^{k+1}}^{(m-1)}-\frac{1}{4^m-1}T_{2^k}^{(m-1)}\),当 \(|T_{2^{k+1}}^{(m)}-T_{2^k}^{(m)}| < ε\) 时,则收敛,并取其中一值作为最终的积分值。未经修饰的代码如下:

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
40
41
42
43
44
45
46
47
function rombergIntegrator(f: (x: number) => number, a: number, b: number, tolerance = 1e-8) {
let h = b - a;
let n = 1;
let preTK = ((f(a) + f(b)) * h) / 2;

let tkmArray = [];
let m = 0;
tkmArray[m] = preTK;

while (true) {
m += 1;
console.log(m, tkmArray[m - 1]);

let tk = getTK(f, preTK, n, a, h);
if (Math.abs(tk - preTK) < tolerance) return tk;

let preTKM = tkmArray[0];
let preTK1M = tk;
tkmArray[0] = tk;
for (let i = 1; i <= m; i++) {
let newTKM = getTKM(preTK1M, preTKM, i);
preTKM = tkmArray[i];
preTK1M = newTKM;
tkmArray[i] = newTKM;

if (preTKM !== undefined && Math.abs(preTK1M - preTKM) < tolerance) return preTK1M;
}

preTK = tk;
n *= 2;
h /= 2;
}

function getTK(f: (x: number) => number, preTK: number, n: number, a: number, h: number) {
let sum = 0;
for (let i = 0; i < n; i++) {
let x = a + (i + 0.5) * h;
sum += f(x);
}
return (preTK + h * sum) / 2;
}

function getTKM(preTK1M: number, preTKM: number, m = 0) {
let m4 = 1 << (2 * m); // 4 ** m;
return (m4 * preTK1M - preTKM) / (m4 - 1);
}
}

  由于采用闭型积分规则(积分上下限值参与积分计算),所以以上代码不适合计算两端点值被积函数值无限大的情况(如 1/4 圆弧积分等)。而且该方法不合适求取被积函数在积分区间内导数值变化较大(如被积函数在积分下限附近剧烈波动,在积分上限附近不变化等)的积分,因为该方法是均匀分段,这种情况将导致计算量剧增,这时就需要用到下面的自适应积分。

自适应篇

  自适应积分主要包括两类:全局自适应积分和局部自适应积分,通常情况下全局自适应积分的会比局部自适应积分的表现要好,全局自适应积分一般通过二分递归实现,当满足一定条件时,递归终止,即通过二分分别计算两边的积分,若一边满足一定条件,则不继续划分,从而减少计算量。全局自适应积分中比较经典的有基于辛普森(Simpson)公式的自适应算法,普通辛普森积分公式为:\(I=\int_a^b{f(x)dx}=\frac{b-a}{6}[f(a)+4f(m)+f(b)]= S(a,b), m=(a+b)/2\),复化辛普森公式为 \(I=\int_a^b{f(x)dx}=\frac{h}{3}[f(a)+4\sum\limits_{i=1}^{n}f(x_{2i-1})+2\sum\limits_{i=1}^{n-1}f(x_{2i})+f(b)]= S(a,b)\),其中 \(x_i=a+i*h (i=1,2,3,...,2n),h=\frac{b-a}{2n}\),基于辛普森公式的自适应基本原理如下:令 \(S_2(a,b) = S(a,m)+S(m,b)\),m 为 a,b 中值,若 \(|S(a,b) - S_2(a,b)| < 15ε\),则取 \(S_2(a,b)\)\(S_2(a,b)+(S(a,b)-S_2(a,b))/15\) 作为该区间的积分值,否则,将区间二分递归,同时因为误差会累积,所以每次递归都需要将精度提高两倍,即 \(ε = ε/2\),如此最后的精度才能达到最初的精度。具体 ts 代码如下:

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
function adaptiveSimpsonIntegrator(f: (x: number) => number, a: number, b: number, epsilon = 1e-8) {
let S = complexSimpson(f, a, b);
return adsimp(f, a, b, epsilon, S);

function adsimp(f: (x: number) => number, a: number, b: number, epsilon = 1e-8, S = 0): number {
let m = a + (b - a) / 2;
let LS = complexSimpson(f, a, m);
let RS = complexSimpson(f, m, b);
const S2 = LS + RS;
const tolerance = 15 * epsilon;
let delta = S - S2;
if (Math.abs(delta) < tolerance) return S2 + delta / 15;
else {
let doubleEPS = epsilon / 2;
return adsimp(f, a, m, doubleEPS, LS) + adsimp(f, m, b, doubleEPS, RS);
}
}

function complexSimpson(f: (x: number) => number, a: number, b: number, n = 1) {
const n2 = n * 2;
const h = (b - a) / n2;
let sum = f(a) + f(b);

for (let i = 1; i < n2; i += 2) {
sum += 4 * f(a + i * h);
}
for (let i = 2; i < n2 - 1; i += 2) {
sum += 2 * f(a + i * h);
}

return (sum * h) / 3;
}
}

  在 D.V. Fedorov 写的「Introduction to Numerical Methods with examples in Javascript」一书中介绍了一种全局自适应方法,即分别使用高阶和低阶的权值分别计算积分,两者之间的差值 \(E\) 作为误差估计,设绝对精度为 \(\delta\) ,相对精度为 \(\epsilon\) ,若 \(|E|<\delta+\epsilon*Q\),Q 为高阶权值计算的积分,则取 Q 作为积分值,否则将积分区间二分,同时使 \(\delta/\sqrt{2}\)\(\epsilon\) 保持不变。具体 ts 代码如下:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function recursiveAdaptiveIntegrator(f: (x: number) => number, a: number, b: number, accuracy = 1e-15) {
return recursiveAdaptiveIntegrate(f, a, b, accuracy);

function recursiveAdaptiveIntegrate(
f: (x: number) => number,
a: number,
b: number,
accuracy = 1e-15,
epsilon = Number.EPSILON,
preFValue?: number[],
): number {
const abscissae = [1 / 6, 2 / 6, 4 / 6, 5 / 6];
const highOrderWeights = [2 / 6, 1 / 6, 1 / 6, 2 / 6];
const lowOrderWeights = [1 / 4, 1 / 4, 1 / 4, 1 / 4];
const isRecompute = [1, 0, 0, 1];
const h = b - a;

const fValue: number[] = [];
if (preFValue === undefined) {
abscissae.forEach((abscissa) => {
const x = a + abscissa * h;
fValue.push(f(x));
});
} else {
for (let k = 0, i = 0; i < abscissae.length; i++) {
if (isRecompute[i]) fValue.push(f(a + abscissae[i] * h));
else fValue.push(preFValue[k++]);
}
}

let highResult = 0;
let lowResult = 0;
for (let i = 0; i < highOrderWeights.length; i++) {
highResult += highOrderWeights[i] * fValue[i];
lowResult += lowOrderWeights[i] * fValue[i];
}
highResult *= h;
lowResult *= h;

const tolerance = accuracy + epsilon * Math.abs(highResult);
let errorEstimate = Math.abs(highResult - lowResult) / 3;
if (errorEstimate < tolerance) {
return highResult;
} else {
accuracy /= Math.sqrt(2);
let m = a + h / 2;
let midIndex = Math.trunc(abscissae.length / 2);
let leftFValue = fValue.slice(0, midIndex);
let rightFValue = fValue.slice(midIndex);
return (
recursiveAdaptiveIntegrate(f, a, m, accuracy, epsilon, leftFValue) +
recursiveAdaptiveIntegrate(f, m, b, accuracy, epsilon, rightFValue)
);
}
}
}

  该方法很巧妙的设计了一组插值点,使得当前计算的函数值正好可以被下次迭代所使用,从而提高性能,同时该方法可以得到 1/4 圆弧长,虽然精度只能到小数点后 8 位,至于 Shaun 写的其它测试函数,都能得到理想精度。

Gauss 篇

  高斯求积法是一种多项式插值积分法,同时由于不计算被积函数在区间两个端点处的值,所以高斯积分法采用的开型积分规则,高斯积分法的衍生方法有很多种,下面主要介绍高斯-勒让德(Gauss-Legendre Quadrature)以及其迭代改良的高斯-克朗罗德法。高斯-勒让德积分法的公式为积分的原始形态,即 \(\int_a^bf(x)dx=\sum\limits_{i=1}^{∞}w_if(x_{i})\approx\sum\limits_{i=1}^{n}w_if(x_{i})\) ,只不过 \(x_i \in [-1,1]\),并且 \(x_i\)\(w_i\) 都通过勒让德多项式求出,所以其原则上只能用在积分区间为 [-1, 1] 上的积分,但是可以将积分从任意区间通过简单的变形变换到 [-1, 1] 上,即 \(\int_a^b{f(x)dx} = \frac{b-a}{2}\int_{-1}^1{f(\frac{b-a}{2}t+\frac{b+a}{2})dt}\) ,从而可以将高斯-勒让德方法扩展到任意积分上。由于每个 n 对应的 \(x_i\)\(w_i\) 都可以查表可得,所以具体代码方面就很简单了,以 n = 4,即插值点个数为 4 为例,ts 代码如下:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
function gaussLegendreIntegrate(f: (x: number) => number, a: number, b: number, n: 4 | 8 = 4) {
const weightsAndAbscissae = getWeightsAndAbscissae(n);
const weights = weightsAndAbscissae.weights;
const abscissae = weightsAndAbscissae.abscissae;

const halfH = (b - a) / 2;

let sum = 0;
for (let i = 0; i < abscissae.length; i++) {
let xi = halfH * abscissae[i] + a + halfH;
sum += weights[i] * f(xi);
}

return sum * halfH;

function getWeightsAndAbscissae(n: 4 | 8 = 4) {
switch (n) {
case 8:
return {
weights: [
0.362683783378362,
0.362683783378362,
0.3137066458778873,
0.3137066458778873,
0.2223810344533745,
0.2223810344533745,
0.1012285362903763,
0.1012285362903763,
],
abscissae: [
-0.1834346424956498,
0.1834346424956498,
-0.525532409916329,
0.525532409916329,
-0.7966664774136267,
0.7966664774136267,
-0.9602898564975363,
0.9602898564975363,
],
};
break;

case 4:
default:
return {
weights: [0.6521451548625461, 0.6521451548625461, 0.3478548451374538, 0.3478548451374538],
abscissae: [-0.3399810435848563, 0.3399810435848563, -0.8611363115940526, 0.8611363115940526],
};
break;
}
}
}

  若要提高高斯-勒让德积分法的精度,可通过增加插值点或分多个区间进行积分来实现,但是由于没有误差估计,所以还是没法精确控制精度,对与某些被积函数积分精度高,但对于其它被积函数,积分精度却有限,当然可以简单的引入一些常用的误差估计法,但一般需要重新计算积分,导致效率很低,而高斯-克朗罗德法为其引入了一种基于 Kronrod 点的误差估计法,可充分利用现有计算值,从而达到有效控制精度的同时,性能没有太大的损失。设 \(G(f,n)=\sum\limits_{i=1}^{n}w_if(x_{i})\) 为具有 n 个插值点的高斯-勒让德法计算结果,\(GK(f,n) = \sum\limits_{i=1}^{n}w'_if(x_{i})+\sum\limits_{k=n+1}^{2n+1}w'_kf(x_{k})\) 为高斯-克朗罗德法的计算结果,则 \(|GK(f,n)-G(f,n)|\) 可作为误差估计,有了误差估计,最后再使用全局自适应策略,即可得到精度可控的高斯积分结果。具体 ts 代码如下,以 n = 7 为例:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
function gaussKronrodIntegrator(f: (x: number) => number, a: number, b: number, accuracy = 1e-15) {
return recursiveAdaptiveIntegrate(f, a, b, accuracy);

function recursiveAdaptiveIntegrate(f: (x: number) => number, a: number, b: number, accuracy = 1e-12): number {
const gaussAbscissae = [
0.0,
-4.058451513773971669066064120769615e-1,
4.058451513773971669066064120769615e-1,
-7.415311855993944398638647732807884e-1,
7.415311855993944398638647732807884e-1,
-9.491079123427585245261896840478513e-1,
9.491079123427585245261896840478513e-1,
];
const gaussWeights = [
4.179591836734693877551020408163265e-1,
3.818300505051189449503697754889751e-1,
3.818300505051189449503697754889751e-1,
2.797053914892766679014677714237796e-1,
2.797053914892766679014677714237796e-1,
1.29484966168869693270611432679082e-1,
1.29484966168869693270611432679082e-1,
];

const kronrodAbscissae = gaussAbscissae.concat([
-2.077849550078984676006894037732449e-1,
2.077849550078984676006894037732449e-1,
-5.860872354676911302941448382587296e-1,
5.860872354676911302941448382587296e-1,
-8.648644233597690727897127886409262e-1,
8.648644233597690727897127886409262e-1,
-9.914553711208126392068546975263285e-1,
9.914553711208126392068546975263285e-1,
]);

const kronrodWeights = [
2.094821410847278280129991748917143e-1,
1.903505780647854099132564024210137e-1,
1.903505780647854099132564024210137e-1,
1.406532597155259187451895905102379e-1,
1.406532597155259187451895905102379e-1,
6.309209262997855329070066318920429e-2,
6.309209262997855329070066318920429e-2,
2.044329400752988924141619992346491e-1,
2.044329400752988924141619992346491e-1,
1.690047266392679028265834265985503e-1,
1.690047266392679028265834265985503e-1,
1.04790010322250183839876322541518e-1,
1.04790010322250183839876322541518e-1,
2.293532201052922496373200805896959e-2,
2.293532201052922496373200805896959e-2,
];

const halfH = (b - a) / 2;

let guassResult = 0;
let kronrodResult = 0;
for (let i = 0; i < gaussAbscissae.length; i++) {
let xi = halfH * gaussAbscissae[i] + a + halfH;
let yi = f(xi);
guassResult += gaussWeights[i] * yi;
kronrodResult += kronrodWeights[i] * yi;
}
for (let i = gaussAbscissae.length; i < kronrodAbscissae.length; i++) {
let xi = halfH * kronrodAbscissae[i] + a + halfH;
let yi = f(xi);
kronrodResult += kronrodWeights[i] * yi;
}

if (Math.abs(kronrodResult - guassResult) < accuracy / halfH) return kronrodResult * halfH;
else {
let m = a + (b - a) / 2;
accuracy /= 2;
return recursiveAdaptiveIntegrate(f, a, m, accuracy) + recursiveAdaptiveIntegrate(f, m, b, accuracy);
}
}
}

  简单测试了一下,Shaun 这里写的 gaussKronrodIntegrator 方法最大精度只能到 1e-15,到 16 位就报错递归深度太大了,圆的 1/4 弧长也没法算出来,当然这些问题可通过设置最大递归深度以及处理异常值来解决,Shaun 这里就不继续写了。

后记

  数值积分策略非常多,尤其是针对一些特殊的函数,可能只能使用一些特殊的策略才能计算,Shaun 这里只是介绍了一些比较基础常用的积分方法,能解决大部分积分问题,唯一需要注意一点的就是如何追求性能与精度之间的平衡,因为积分常常涉及到迭代求值,通常而言精度越高,迭代越多,求积时,同时也需要注意被积函数的异常值(如无穷大等),这时可能需要拆分或变换积分区间,并且使用开型积分规则的积分方法进行重新计算。

附录

一些常见的积分变换

\[ \int_a^bf(x) = \int_0^{b-a}f(a+t)dt \\ \int_a^b{f(x)dx} = \frac{b-a}{2}\int_{-1}^1{f(\frac{b-a}{2}t+\frac{b+a}{2})dt} \\ \int_{-∞}^{+∞}{f(x)dx} = \int_{-1}^1{f(\frac{t}{1-t^2})\frac{1+t^2}{(1-t^2)^2}dt} \\ \int_{a}^{+∞}{f(x)dx} = \int_{0}^1{f(a + \frac{t}{1-t})\frac{1}{(1-t)^2}dt} \\ \int_{-∞}^{a}{f(x)dx} = \int_{-1}^0{f(a + \frac{t}{1+t})\frac{-1}{(1+t)^2}dt} \]

参考资料

[1] 积分策略

[2] Gaussian Quadrature Weights and Abscissae

[3] Gauss-Kronrod Quadrature

[4] Gauss-Kronrod Quadrature Nodes and Weights