2024年2月

implement-run-cmd.png

本文为从零开始写 Docker 系列第一篇,主要实现 mydocker run 命令,构造了一个具有基本的 Namespace 隔离的简单容器。


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅




本文主要实现我们的第一个命令
mydocker run
,类似于
docker run -it [command]

docker run 命令是通过创建新的 namespace 对新的进程进行视图隔离。

完整代码见:
https://github.com/lixd/mydocker
欢迎 Star


推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.2 LTS
Release:	20.04
Codename:	focal
root@mydocker:~# uname -r
5.4.0-74-generic


注意:需要使用 root 用户

urfave/cli 工具

主要用到了
urfave/cli
来实现命令行工具,具体用法参考官方文档。

两个常用 cli 库对比:

urfave/cli 比较简洁,实现简单的 cli 工具推荐使用。
spf13/cobra
功能强大,实现复杂的 cli 工具推荐使用。

一个简单的 urfave/cli Demo 如下:

// urfaveCli cli 包简单使用,具体可以参考官方文档
func urfaveCli() {
	app := cli.NewApp()

	// 指定全局参数
	app.Flags = []cli.Flag{
		cli.StringFlag{
			Name:  "lang, l",
			Value: "english",
			Usage: "Language for the greeting",
		},
		cli.StringFlag{
			Name:  "config, c",
			Usage: "Load configuration from `FILE`",
		},
	}
	// 指定支持的命令列表
	app.Commands = []cli.Command{
		{
			Name:    "complete",
			Aliases: []string{"c"},
			Usage:   "complete a task on the list",
			Action: func(c *cli.Context) error {
				log.Println("run command complete")
				for i, v := range c.Args() {
					log.Printf("args i:%v v:%v\n", i, v)
				}
				return nil
			},
		},
		{
			Name:    "add",
			Aliases: []string{"a"},
			// 每个命令下面还可以指定自己的参数
			Flags: []cli.Flag{cli.Int64Flag{
				Name:  "priority",
				Value: 1,
				Usage: "priority for the task",
			}},
			Usage: "add a task to the list",
			Action: func(c *cli.Context) error {
				log.Println("run command add")
				for i, v := range c.Args() {
					log.Printf("args i:%v v:%v\n", i, v)
				}
				return nil
			},
		},
	}

	err := app.Run(os.Args)
	if err != nil {
		log.Fatal(err)
	}
}

具体效果如下:

$ go run main.go -h
NAME:
   main - A new cli application

USAGE:
   main [global options] command [command options] [arguments...]

COMMANDS:
   complete, c  complete a task on the list
   add, a       add a task to the list
   help, h      Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --lang value, -l value  Language for the greeting (default: "english")
   --config FILE, -c FILE  Load configuration from FILE
   --help, -h              show help

可以看到指定的指令和参数,就是这么简单。

具体实现

目录结构

mydocker 项目当前目录结构如下:

$ tree .
.
├── LICENSE
├── Makefile
├── README.md
├── container
│   ├── container_process.go
│   └── init.go
├── example
│   └── main.go
├── go.mod
├── go.sum
├── main.go
├── main_command.go
└── run.go
  • main.go 作为项目入口
  • main_command.go 中包含了所有的 command
  • run.go 则是 run 命令核心逻辑
  • container 目录则是一些 container 的核心实现

main.go

首先是 main 文件:

使用 urfave/cli 提供 的命令行工具定义了 mydocker 的几个基本命令,包括
runCommand

initCommand
,然后在 app.Before 内初始化 logrus 的日志配置。

package main

import (
	"os"

	log "github.com/sirupsen/logrus"

	"github.com/urfave/cli"
)

const usage = `mydocker is a simple container runtime implementation.
			   The purpose of this project is to learn how docker works and how to write a docker by ourselves
			   Enjoy it, just for fun.`

func main() {
	app := cli.NewApp()
	app.Name = "mydocker"
	app.Usage = usage

	app.Commands = []cli.Command{
		initCommand,
		runCommand,
	}

	app.Before = func(context *cli.Context) error {
		// Log as JSON instead of the default ASCII formatter.
		log.SetFormatter(&log.JSONFormatter{})

		log.SetOutput(os.Stdout)
		return nil
	}

	if err := app.Run(os.Args); err != nil {
		log.Fatal(err)
	}
}

main_command.go

main_command 中包含了具体的命令定义:

var runCommand = cli.Command{
	Name: "run",
	Usage: `Create a container with namespace and cgroups limit
			mydocker run -it [command]`,
	Flags: []cli.Flag{
		cli.BoolFlag{
			Name:  "it", // 简单起见,这里把 -i 和 -t 参数合并成一个
			Usage: "enable tty",
		},
	},
	/*
		这里是run命令执行的真正函数。
		1.判断参数是否包含command
		2.获取用户指定的command
		3.调用Run function去准备启动容器:
	*/
	Action: func(context *cli.Context) error {
		if len(context.Args()) < 1 {
			return fmt.Errorf("missing container command")
		}
		cmd := context.Args().Get(0)
		tty := context.Bool("it")
		Run(tty, cmd)
		return nil
	},
}

var initCommand = cli.Command{
	Name:  "init",
	Usage: "Init container process run user's process in container. Do not call it outside",
	/*
		1.获取传递过来的 command 参数
		2.执行容器初始化操作
	*/
	Action: func(context *cli.Context) error {
		log.Infof("init come on")
		cmd := context.Args().Get(0)
		log.Infof("command: %s", cmd)
		err := container.RunContainerInitProcess(cmd, nil)
		return err
	},
}

要实现 run 命令我们需要实现 run、init 两个命令。

run.go

接着看下 Run 函数做了写什么:

// Run 执行具体 command
/*
这里的Start方法是真正开始前面创建好的command的调用,它首先会clone出来一个namespace隔离的
进程,然后在子进程中,调用/proc/self/exe,也就是调用自己,发送init参数,调用我们写的init方法,
去初始化容器的一些资源。
*/
func Run(tty bool, cmd string) {
	parent := container.NewParentProcess(tty, cmd)
	if err := parent.Start(); err != nil {
		log.Error(err)
	}
	_ = parent.Wait()
	os.Exit(-1)
}

Run 命令主要调用 NewParentProcess 构建 os/exec.Cmd 对象并执行,执行完成立马退出。

注意区分 os/exec 包中的 Cmd 对象和 urfave/cli 包中的 command 对象。

// NewParentProcess 启动一个新进程
/*
这里是父进程,也就是当前进程执行的内容。
1.这里的/proc/se1f/exe调用中,/proc/self/ 指的是当前运行进程自己的环境,exec 其实就是自己调用了自己,使用这种方式对创建出来的进程进行初始化
2.后面的args是参数,其中init是传递给本进程的第一个参数,在本例中,其实就是会去调用initCommand去初始化进程的一些环境和资源
3.下面的clone参数就是去fork出来一个新进程,并且使用了namespace隔离新创建的进程和外部环境。
4.如果用户指定了-it参数,就需要把当前进程的输入输出导入到标准输入输出上
*/
func NewParentProcess(tty bool, command string) *exec.Cmd {
	args := []string{"init", command}
	cmd := exec.Command("/proc/self/exe", args...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
			syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
	}
	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}
	return cmd
}

可以看到,NewParentProcess 方法构建的命令为
/proc/self/exe
,这个表示调用
当前文件
,我们后续编译出来是二进制文件是 mydocker,那么这个命令执行的就是 mydocker。
第一个参数为 init,也就是说这个命令最终会执行
mydocker init
这个命令。

这也就是为什么我们除了实现 run 命令之外,还要实现一个 init 命令。

另外比较重要的就是关于 tty 的:

	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}

将 cmd 的输入和输出连接到终端,以便我们可以与命令进行交互,并看到命令的输出。

即:cmd 可以从标准输入读取输入,也可以把结果打印到标准输出或者错误输出上。

那么当我们执行
mydocker run -it /bin/ls
这种命令时,最后的
/bin/ls
就会作为标准输入给到容器进程,因此容器进程就会执行
/bin/ls
命令,列出当前目录下的文件。

init.go

最后再看下 initCommand 的具体内容:

// RunContainerInitProcess 启动容器的init进程
/*
这里的init函数是在容器内部执行的,也就是说,代码执行到这里后,容器所在的进程其实就已经创建出来了,
这是本容器执行的第一个进程。
使用mount先去挂载proc文件系统,以便后面通过ps等系统命令去查看当前进程资源的情况。
*/
func RunContainerInitProcess(command string, args []string) error {
	log.Infof("command:%s", command)
	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
	_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
	argv := []string{command}
	if err := syscall.Exec(command, argv, os.Environ()); err != nil {
		log.Errorf(err.Error())
	}
	return nil
}

这里 Mount 意思如下:

  • MS_NOEXEC 在本文件系统 许运行其 程序。

  • MS_NOSUID 在本系统中运行程序的时候, 允许 set-user-ID set-group-ID

  • MS_NOD 这个参数是自 Linux 2.4 ,所有 mount 的系统都会默认设定的参数。

本函数最后的
syscall.Exec
是最为重要的一句黑魔法,正是这个系统调用实现了完成初始化动作并将用户进程运行起来的操作。

首先,使用 Docker 创建起来一个容器之后,会发现容器内的第一个程序,也就是 PID 为 1 的那个进程,是指定的前台进程。但是,我们知道容器创建之后,执行的第一个进程并不是用户的进程,而是 init 初始化的进程。 这时候,如果通过 ps 命令查看就会发现,容器内第一个进程变成了自己的 init,这和预想的是不一样的。

有没有什么办法把自己的进程变成 PID 为 1 的进程呢?

这里 execve 系统调用就是用来做这件事情的。

syscall.Exec 这个方法,其实最终调用了 Kernel 的
int execve(const char *filename, char *const argv[], char *const envp[);
这个系统函数。
它的作用是执行当前 filename 对应的程序,它会覆盖当前进程的镜像、数据和堆栈等信息,包括 PID,这些都会被将要运行的进程覆盖掉

也就是说,调用这个方法,将用户指定的进程运行起来,把最初的 init 进程给替换掉,这样当进入到容器内部的时候,就会发现容器内的第一个程序就是我们指定的进程了。

这其实也是目前 Docker 使用的容器引擎 runC 的实现方式之一。

具体启动流程如下图:

mydocker-run-exec-process.png

  • 1)流程开始,用户手动执行 mydocker run 命令
  • 2)urfave/cli 工具解析传递过来的参数
  • 3)解析完成后发现第一个参数是 run,于是执行 run 命令,调用 runCommand 方法,该方法中继续调用 NewParentProcess 函数构建一个 cmd 对象
  • 4)NewParentProcess 将构建好的 cmd 对象返回给 runCommand 方法
  • 5)runCommand 方法中调用 cmd.exec 执行上一步构建好的 cmd 对象
  • 6)容器启动后,根据 cmd 中传递的参数,/proc/self/exe init 实则最终会执行 mydocker init 命令,初始化容器环境
  • 7)init 命令内部实现就是通过 mount 命令挂载 proc 文件系统
  • 8)容器创建完成,整个流程结束

测试

root@mydocker:~/mydocker# go build .
root@mydocker:~/mydocker# ./mydocker run -it /bin/sh
{"level":"info","msg":"init come on","time":"2024-01-03T14:44:35+08:00"}
{"level":"info","msg":"command: /bin/sh","time":"2024-01-03T14:44:35+08:00"}
{"level":"info","msg":"command:/bin/sh","time":"2024-01-03T14:44:35+08:00"}
# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 09:47 pts/1    00:00:00 /bin/sh
root           5       1  0 09:47 pts/1    00:00:00 ps -ef

在容器运行 ps 时,可以发现 /bin/sh 程是容器内的第一个进程, PID 为 1。 ps 进程是 PID 为 1 的父进程创建出来的。

来对比 Docker 运行的容器的效果,如下:

[root@docker ~]# docker run -it ubuntu /bin/sh
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 01:49 pts/0    00:00:00 /bin/sh
root         7     1  0 01:49 pts/0    00:00:00 ps -ef

不能说很相似,只能说是一模一样了。

这里的 /bin/sh 是一个会在前台一直运行的进程,那么可以试一下如果指定一个运行完就会退出的进程会是什么效果:

root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"info","msg":"init come on","time":"2024-01-03T14:51:48+08:00"}
{"level":"info","msg":"command: /bin/ls","time":"2024-01-03T14:51:48+08:00"}
{"level":"info","msg":"command:/bin/ls","time":"2024-01-03T14:51:48+08:00"}
LICENSE  Makefile  README.md  container  example  go.mod  go.sum  main.go  main_command.go  mydocker  run.go

运行了一下 ls 命令,发现容器启动起来以后,打印出了当前目录的内容,然后便退出了,这个结果和 Docker 要求容器必须有一个一直在前台运行的进程的要求一致。

由于没有 chroot ,所以目前的系统文件系统是继承自父进程的。

至此,我们的 mydocker run 命令就算是实现完成,基本能实现 docker run 的效果。

小结

run 命令实现中感觉几个比较重要的点:

  • /proc/self/exe
    :调用自身 init 命令,初始化容器环境
  • tty
    :实现交互
  • Namespace 隔离
    :通过在 fork 时指定对应 Cloneflags 来实现创建新 Namespace
  • proc 隔离
    :通过重新 mount /proc 文件系统来实现进程信息隔离
  • execve 系统调用
    :使用指定进程覆盖 init 进程

/proc/self/exe

/proc/self/exe
是 Linux 系统中的一个符号链接,
它指向当前进程的可执行文件

这个路径是一个虚拟路径,实际上并不对应于文件系统中的一个文件,而是通过 /proc 文件系统提供的一种方式来访问进程相关的信息。

具体而言,/proc/self 是一个指向当前进程自身的符号链接,而 exe 则是一个特殊的文件,通过这个文件可以访问当前进程的可执行文件。

因此,/proc/self/exe 实际上是当前进程可执行文件的路径

也就是说在 mydocker run 命令中执行的 /proc/self/exe init 实际上最终执行的是 mydocker init,即 run 命令会调用 init 命令来初始化容器环境。

tty

	if tty {
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
	}

当用户指定 -it 参数时,就将 cmd 的输入和输出连接到终端,以便我们可以与命令进行交互,并看到命令的输出。

Namespace 隔离

	cmd := exec.Command("/proc/self/exe", args...)
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
			syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
	}

fork 新进程时,通过指定 Cloneflags 会创建对应的 Namespace 以实现隔离,这里包括UTS(主机名)、PID(进程ID)、挂载点、网络、IPC等方面的隔离。

proc 隔离

/proc 文件系统是一个虚拟的文件系统,提供了对内核和运行中进程的信息的访问。通过挂载 /proc,系统中的许多信息和控制接口可以通过文件的形式在这个目录下找到。

例如,你可以通过 /proc 查看系统的一些信息,如进程列表、内存使用情况、CPU 信息等。

比如当前机器上的进程信息:

[root@docker ~]# ls /proc
1     1147  16     18531  20     247    325  350  468  632  74    79   85    908        cmdline    driver       ioports    kpagecgroup  misc          pressure     stat           tty
10    1150  16968  18533  20728  3      326  351  491  633  75    8    86    978        consoles   execdomains  irq        kpagecount   modules       sched_debug  swaps          uptime
100   1152  17     18534  21     31652  327  352  508  635  76    80   87    acpi       cpuinfo    fb           kallsyms   kpageflags   mounts        schedstat    sys            version
1094  1155  18     18549  22     31797  333  353  544  637  7675  813  88    buddyinfo  crypto     filesystems  kcore      loadavg      mtrr          scsi         sysrq-trigger  vmallocinfo
11    13    18433  18550  244    31818  347  354  587  641  7694  82   8897  bus        devices    fs           keys       locks        net           self         sysvipc        vmstat
1100  14    18435  19     245    323    348  355  6    643  77    832  89    capi       diskstats  interrupts   key-users  mdstat       pagetypeinfo  slabinfo     thread-self    zoneinfo
1141  15    18505  2      246    324    349  4    620  73   78    84   9     cgroups    dma        iomem        kmsg       meminfo      partitions    softirqs     timer_list

而在容器环境中,为了和宿主机的 /proc 环境隔离,因此在
mydocker init
命令中我们会重新挂载 /proc 文件系统,即:

syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

对应 mount 命令为:

mount -t proc proc /proc

而当前进程在 fork 时指定了
syscall.CLONE_NEWPID
等等标记,因此是在新的 Namespace 中的,那就意味着看不到宿主机上的进程信息,那么重新挂载后的 /proc 文件系统自然也就只有当前 Namespace 下的进程信息。

这也就是为什么在容器中执行 ps 命令只能看到容器中的进程信息

execve 系统调用

execve 系统调用用于取代当前进程的映像(即,当前进程的可执行文件),并用一个新的程序来替代

原型如下:

int execve(const char *filename, char *const argv[], char *const envp[);
  • filename
    参数指定了要执行的新程序的文件路径。
  • argv
    参数是一个字符串数组,包含了新程序的命令行参数。数组的第一个元素通常是新程序的名称,随后的元素是命令行参数。
  • envp
    参数是一个字符串数组,包含了新程序执行时使用的环境变量。

execve 的工作方式是加载指定的程序文件,并将它替代当前进程的内存映像。因此,执行 execve 后,原进程的代码、数据等内容都会被新程序的内容替代。

即:它的作用是执行当前 filename 对应的程序,它会覆盖当前进程的镜像、数据和堆栈等信息,包括 PID,这些都会被将要运行的进程覆盖掉。

在 Go 中的调用方式为
syscall.Exe
。 通过该系统调用,可以使用用户指定的命令启动新进程来覆盖 mydocker 进程作为容器环境中的 PID 1 进程。

即:在 init 命令中解析拿到用户指定的命令并通过
syscall.Exe
使用该命令创建新进程来覆盖 mydocker 进程。

这也就是为什么我们执行
mydocker run -it /bin/sh
后 sh 会成为 PID 1 进程。

看到这里的话,mydocker run 命令的具体实现及其关键点,都完整介绍了一遍了,再回过头来看一下具体流程,应该就更清晰了:
mydocker-run-exec-process.png

  • 1)流程开始,用户手动执行 mydocker run 命令
  • 2)urfave/cli 工具解析传递过来的参数
  • 3)解析完成后发现第一个参数是 run,于是执行 run 命令,调用 runCommand 方法,该方法中继续调用 NewParentProcess 函数构建一个 cmd 对象
  • 4)NewParentProcess 将构建好的 cmd 对象返回给 runCommand 方法
  • 5)runCommand 方法中调用 cmd.exec 执行上一步构建好的 cmd 对象
  • 6)容器启动后,根据 cmd 中传递的参数,/proc/self/exe init 实则最终会执行 mydocker init 命令,初始化容器环境
  • 7)init 命令内部实现就是通过 mount 命令挂载 proc 文件系统
  • 8)容器创建完成,整个流程结束

FAQ

以下是在实现 mydocker run 命令时可能出现的问题

对应代码已经提交,使用本仓库代码不会出现该问题

fork/exec /proc/self/exe: no such file or directory

在正常第二次 mydocker 命令时出现该错误,具体如下:

root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"info","msg":"init come on","time":"2024-01-03T15:07:27+08:00"}
{"level":"info","msg":"command: /bin/ls","time":"2024-01-03T15:07:27+08:00"}
{"level":"info","msg":"command:/bin/ls","time":"2024-01-03T15:07:27+08:00"}
LICENSE  Makefile  README.md  container  example  go.mod  go.sum  main.go  main_command.go  mydocker  run.go
root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"error","msg":"fork/exec /proc/self/exe: no such file or directory","time":"2024-01-03T15:07:28+08:00"}

原因

这个是因为代码中会将容器进程的 proc 信息挂载为 proc 文件系统,具体代码如下:

// container/init.go#RunContainerInitProcess 方法
	defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

这部分代码会在 mydocker init 中执行,也就是说实际上是在容器进程中执行的 mount,当我们的 mydocker 进程运行结束退出后,容器进程就消失了。

而在引入了 systemd 之后的 linux 中,mount namespace 是 shared by default,也就是说宿主机上的 /proc 目录也被影响了。

即:宿主机 /proc 目录的内容依旧是运行 mydocker 时的信息,而此时因为 mydocker 已经退出了,对应的进程信息自然就不存在了,所以会在执行 mydocker run 中的
/proc/self/exe
这个命令时出现这个错误。

解决方案

临时解决方案
:在宿主机手动执行一次 mount,重新挂载 /proc 目录,即可将 /proc 目录恢复为正常数据

mount -t proc proc /proc

后续每次运行 mydocker 命令都会破坏掉 /proc 目录数据。

永久解决方案
:将 mount 事件显式指定为 private 即可避免挂载事件外泄,这样就不会破坏宿主机 /proc 目录数据了。
具体代码调整如下:

// container/init.go#RunContainerInitProcess 方法
// systemd 加入linux之后, mount namespace 就变成 shared by default, 所以你必须显示声明你要这个新的mount namespace独立。
// 即 mount proc 之前先把所有挂载点的传播类型改为 private,避免本 namespace 中的挂载事件外泄。
syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
// 如果不先做 private mount,会导致挂载事件外泄,后续再执行 mydocker 命令时 /proc 文件系统异常
// 可以执行 mount -t proc proc /proc 命令重新挂载来解决
// ---分割线---
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

核心为这一句:

syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")

把所有挂载点的传播类型改为 private,避免本 namespace 中的挂载事件外泄。

相关讨论:
#33

#41#issuecomment-478799767

#58


如果你对云原生技术充满好奇,想要深入了解更多相关的文章和资讯,欢迎关注微信公众号。

搜索公众号【
探索云原生
】即可订阅


完整代码见:
https://github.com/lixd/mydocker
欢迎 Star

相关代码见 feat-run 分支,测试脚本如下:

# 克隆代码
git clone -b feat-run https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试
./mydocker run -it /bin/ls

从零开始学Spring Boot系列-前言

在数字化和信息化的时代,Java作为一种成熟、稳定且广泛应用的编程语言,已经成为构建企业级应用的首选。而在Java生态系统中,Spring框架无疑是其中最为耀眼的一颗明星。它提供了全面的编程和配置模型,用于构建企业级应用。随着Spring Boot的出现,这一框架变得更加易于使用,使得开发者能够更快速地构建、部署和管理生产级的Spring应用。

为什么选择Spring Boot?

  1. 简化配置:Spring Boot通过约定大于配置的理念,为开发者提供了大量的默认配置,从而减少了繁琐的配置工作。它采用了自动配置机制,可以自动检测和配置项目所需的组件和设置,大大降低了配置的难度和出错率。
  2. 快速搭建项目:Spring Boot提供了大量的起步依赖(Starters),这些预定义的依赖集合包含了常见的库和框架,使得开发者能够快速地添加所需的功能。通过简单的Maven或Gradle依赖管理,即可快速地搭建起一个完整的项目。
  3. 内嵌服务器:Spring Boot内置了Tomcat、Jetty或Undertow等Web服务器,使得开发者无需额外部署Web服务器即可运行和测试应用。这大大简化了应用的部署流程,并且使得开发和测试更加便捷。
  4. 生产就绪:Spring Boot提供了许多用于监控、管理和调优应用的工具,如健康检查、指标监控和性能分析等。这些工具可以帮助开发者更好地管理和维护生产环境中的应用。
  5. 丰富的插件生态:Spring Boot拥有庞大的社区和丰富的插件生态,提供了各种常用的功能和扩展。无论是数据库访问、消息队列、缓存管理还是安全性等,都有相应的插件和库可供选择。
  6. 易于集成:Spring Boot与其他Spring项目(如Spring Cloud、Spring Data等)的集成非常便捷。它提供了统一的编程模型和配置方式,使得开发者能够轻松地构建微服务架构和分布式系统。
  7. 易于学习和使用:Spring Boot的文档丰富、社区活跃,并且提供了大量的教程和示例。这使得开发者能够更快速地学习和掌握Spring Boot的使用,降低了学习曲线。

需要哪些基础知识?

在学习Spring Boot之前,我们需要确保已经掌握了一些必要的基础知识。这些基础知识包括:

  1. Java编程语言基础:熟悉Java编程语言的核心概念,如变量、数据类型、条件语句、循环、异常处理、集合、泛型等。同时,深入理解Java的面向对象编程特性,如类、对象、继承、封装和多态。
  2. Java Web开发基础:了解Java Web开发的基本概念,如Servlet、JSP、HTTP协议、Web服务器等。这对于后续使用Spring Boot构建Web应用程序将非常有帮助。
  3. Spring框架基础:熟悉Spring框架的核心概念,如依赖注入(DI)、面向切面编程(AOP)、事务管理等。这将有助于你更好地理解Spring Boot的工作原理和扩展性。
  4. Maven或Gradle构建工具:掌握Maven或Gradle的基本用法和配置,这对于管理Spring Boot项目的依赖、构建和打包应用程序是至关重要的。
  5. 数据库基础:了解关系型数据库(如MySQL、Oracle)或非关系型数据库(如MongoDB、Redis)的基本概念和操作。这将有助于你在实际应用中处理数据库交互。
  6. Web前端基础:虽然不是必须的,但了解一些基本的Web前端技术,如HTML、CSS、JavaScript,将有助于你更好地实现前后端交互,提升整体开发效率。

当你掌握了这些基础知识后,就可以更加自信地开始学习Spring Boot了。Spring Boot提供了丰富的功能和灵活的配置选项,但只要我们掌握了其核心理念和用法,就能够轻松地构建出高效、稳定的企业级应用。

开发工具与开发环境介绍

在开始Spring Boot的学习之旅前,我们需要先准备好合适的开发工具和开发环境。以下是一些推荐的组合:

  1. IDE(集成开发环境)


    • IntelliJ IDEA:由JetBrains开发的强大IDE,支持多种编程语言,包括Java。它提供了丰富的插件生态,支持Spring Boot的一键式创建和部署,以及强大的代码提示和调试功能。
    • Eclipse:Eclipse是一个流行的开源IDE,同样支持Java和Spring Boot开发。尽管在Spring Boot的支持方面可能不如IntelliJ IDEA全面,但它仍然是许多开发者的首选。
  2. 构建工具


    • Maven:Maven是一个流行的Java项目构建和管理工具。它可以帮助你管理项目的依赖关系,自动化构建过程,并提供了一致的构建输出。Spring Boot项目通常使用Maven作为构建工具。
    • Gradle:Gradle是另一个流行的构建工具,与Maven类似,但具有更好的灵活性和性能。它同样适用于Spring Boot项目。
  3. 数据库


    • MySQL:MySQL是一个广泛使用的开源关系型数据库管理系统。Spring Boot提供了对MySQL的内置支持,因此它成为许多开发者的首选数据库。
    • H2 Database:H2是一个轻量级的嵌入式数据库,常用于开发和测试阶段。由于它不需要额外的配置和安装,因此非常适合在开发过程中使用。
  4. 其他工具


    • Spring Initializr:Spring Initializr是一个Web应用,允许你快速生成一个基本的Spring Boot项目结构。你只需要选择所需的依赖和版本,它就会为你生成一个可运行的Maven或Gradle项目。
    • Lombok:Lombok是一个Java库,它通过注解减少了大量常见的样板代码,如getter、setter、equals、hashCode等。它可以使你的代码更加简洁和易读。

如何开始?

在开始编写Spring Boot代码之前,你需要确保你的开发环境已经正确配置。这包括安装IDE、构建工具、数据库等。此外,熟悉这些工具的基本用法也是非常重要的。

一旦你的开发环境准备就绪,你可以使用Spring Initializr生成一个基本的Spring Boot项目。然后,你可以开始探索Spring Boot的核心特性,如自动配置、起步依赖、数据访问等。

有多个网站可以学习Spring Boot,以下是其中一些推荐:

适合读者

这个系列文章适合对Java和Spring框架有一定了解的开发者。如果你是Spring Boot的新手,或者想要更深入地了解Spring Boot,那么这个系列文章将是你理想的学习资源。

如何学习这个系列文章?

我建议读者按照文章的顺序进行学习,逐步掌握Spring Boot的基础知识和核心特性。同时,我也鼓励读者在学习过程中多动手实践,通过实际的项目应用来巩固所学知识。

在这个信息爆炸的时代,知识的学习不再是难题,难的是如何有效地吸收和应用这些知识。希望通过这个系列文章,能够帮助读者更好地学习和应用Spring Boot,为构建更加高效、稳定的企业级应用打下坚实的基础。


源文来自:
https://daimajiangxin.cn

跨越千年医学对话:用AI技术解锁中医古籍知识,构建能够精准问答的智能语言模型,成就专业级古籍解读助手(LLAMA)

介绍:首先在
Ziya-LLaMA-13B-V1
基线模型的基础上加入中医教材、中医各类网站数据等语料库,训练出一个具有中医知识理解力的预训练语言模型(pre-trained model),之后在此基础上通过海量的中医古籍指令对话数据及通用指令数据进行有监督微调(SFT),使得模型具备中医古籍知识问答能力。

0.模型信息 Model Information

需求 Demand 任务 Task 系列 Series 模型 Model 参数 Parameter 额外 Extra
通用 General AGI 模型 姜子牙 Ziya LLaMA 13B English&Chinese
  • Brief Introduction
    姜子牙通用大模型V1是基于LLaMa的130亿参数的大规模预训练模型,具备翻译,编程,文本分类,信息抽取,摘要,文案生成,常识问答和数学计算等能力。目前姜子牙通用大模型已完成大规模预训练、多任务有监督微调和人类反馈学习三阶段的训练过程。
pip install torch==1.12.1 tokenizers==0.13.3 git+https://github.com/huggingface/transformers

0.1继续预训练 Continual pretraining

原始数据包含英文和中文,其中英文数据来自 openwebtext、Books、Wikipedia 和 Code,中文数据来自清洗后的悟道数据集、自建的中文数据集。在对原始数据进行去重、模型打分、数据分桶、规则过滤、敏感主题过滤和数据评估后,最终得到 125B tokens 的有效数据。

为了解决 LLaMA 原生分词对中文编解码效率低下的问题,我们在 LLaMA 词表的基础上增加了 7k + 个常见中文字,通过和 LLaMA 原生的词表去重,最终得到一个 39410 大小的词表,并通过复用 Transformers 里 LlamaTokenizer 来实现了这一效果。

在增量训练过程中,我们使用了 160 张 40GB 的 A100,采用 2.6M tokens 的训练集样本数量和 FP 16 的混合精度,吞吐量达到 118 TFLOP per GPU per second。因此我们能够在 8 天的时间里在原生的 LLaMA-13B 模型基础上,增量训练 110B tokens 的数据。

训练期间,虽然遇到了机器宕机、底层框架 bug、loss spike 等各种问题,但我们通过快速调整,保证了增量训练的稳定性。我们也放出训练过程的 loss 曲线,让大家了解可能出现的问题。

0.2 多任务有监督微调 Supervised finetuning

在多任务有监督微调阶段,采用了课程学习(curiculum learning)和增量训练(continual learning)的策略,用大模型辅助划分已有的数据难度,然后通过 “Easy To Hard” 的方式,分多个阶段进行 SFT 训练。

SFT 训练数据包含多个高质量的数据集,均经过人工筛选和校验:

  • Self-Instruct 构造的数据(约 2M):BELLE、Alpaca、Alpaca-GPT4 等多个数据集
  • 内部收集 Code 数据(300K):包含 leetcode、多种 Code 任务形式
  • 内部收集推理 / 逻辑相关数据(500K):推理、申论、数学应用题、数值计算等
  • 中英平行语料(2M):中英互译语料、COT 类型翻译语料、古文翻译语料等
  • 多轮对话语料(500K):Self-Instruct 生成、任务型多轮对话、Role-Playing 型多轮对话等

0.3 人类反馈学习 Human-Feedback training

为了进一步提升模型的综合表现,使其能够充分理解人类意图、减少 “幻觉” 和不安全的输出,基于指令微调后的模型,进行了人类反馈训练(Human-Feedback Training,HFT)。在训练中,我们采用了以人类反馈强化学习(RM、PPO)为主,结合多种其他手段联合训练的方法,手段包括人类反馈微调(Human-Feedback Fine-tuning,HFFT)、后见链微调(Chain-of-Hindsight Fine-tuning,COHFT)、AI 反馈(AI Feedback)和基于规则的奖励系统(Rule-based Reward System,RBRS)等,用来弥补 PPO 方法的短板,加速训练。

我们在内部自研的框架上实现了 HFT 的训练流程,该框架可以利用最少 8 张 40G 的 A100 显卡完成 Ziya-LLaMA-13B-v1 的全参数训练。在 PPO 训练中,我们没有限制生成样本的长度,以确保长文本任务的奖励准确性。每次训练的总经验池尺寸超过 100k 样本,确保了训练的充分性。

1.训练数据

1.1 继续预训练数据(纯文本语料)约0.5G

包含两部分:①中医教材数据:收集“十三五”规划所有中医教材共22本。②在线中医网站数据:爬取中医世家、民间医学网等在线中医网站及知识库。

  • 通用指令微调数据

Alpaca-GPT4
52k 中文

  • alpaca_gpt4_data.json
    包含由 GPT-4 生成的 52K 指令跟随数据,并带有 Alpaca 提示。该 JSON 文件与 Alpaca 数据具有相同的格式,只是输出由 GPT-4 生成的:


    • instruction
      :
      str
      ,描述模型应执行的任务。每条 52K 指令都是唯一的。
    • input
      :
      str
      ,任务的任选上下文或输入。
    • output
      :
      str
      ,指令的答案由生成
      GPT-4
  • alpaca_gpt4_data_zh.json
    包含由 GPT-4 生成的 52K 指令跟踪数据,并由 ChatGPT 翻译成中文的 Alpaca 提示。此 JSON 文件具有相同的格式。

  • comparison_data.json
    通过要求GPT-4评估质量,对GPT-4、GPT-3.5和OPT-IML等透明模型的响应进行排名。


    • user_input
      :
      str
      ,用于查询LLM的提示。
    • completion_a

      str
      ,一个模型完成,其排名完成_b。
    • completion_b
      :
      str
      ,不同的模型完成,其质量得分较低。
  • unnatural_instruction_gpt4_data.json
    包含由 GPT-4 生成的 9K 指令跟随数据,并带有非自然指令中的提示。此 JSON 文件与 Alpaca 数据具有相同的格式。

  • 比较了两种指令调整的 LLaMA 模型,分别根据 GPT-4 和 GPT-3 生成的数据进行微调。
  • 在“有用性”标准中,LLaMA-GPT-4 的表现明显优于 LLaMA-GPT-3。
  • LLaMA-GPT-4 在所有三个标准中的表现与原始 GPT-4 相似,这为开发最先进的遵循指令的 LLM 提供了一个有希望的方向。

1.2 中医古籍指令对话数据

  • 语料库来源
    以《中华医典》数据库为语料来源,约338MB,由两部分组成:①非结构化的“古籍文本”:涵盖了886本标点符号及内容完整的中医古籍。②结构化的“古籍辞典”:包含“名医”、“名言”、“名词”、“名著”等六大类,由中医学界诸多知名学者对中医古籍内容知识进一步系统提炼整理,是中医古籍内容精华最为直接的集中体现。

构建指令微调对话数据集
通过知识引导的指令数据生成和指令数据质量优化两个阶段,最终获得504372个对话数据。

  1. 知识引导的指令数据生成
    让ChatGPT基于对该段中医古籍的知识内容理解,模拟用户与AI,通过自问自答的方式,生成逻辑关系相关的若干问题和答案,从而保证对话数据的准确性和可靠性。
  2. 指令数据质量优化
    尽管基于知识引导使得生成的指令数据基于特定领域,并且与所提供的无监督文本内容相关,避免了模型内部“已有知识”的干扰。然而这种方法难以对数据质量进行监督和控制,也难以保证指令数据的多样性和难度,这可能导致大模型对指令数据集的过度拟合。为了解决这个问题,我们在现有指令数据集的基础上,通过指令数据过滤-指令数据整合两个阶段对数据进行二次优化。

中医古籍指令数据种类概览(共504372条对话数据)

对话类型 数量 对话类型 数量
医经理论 40410 温病类 10288
诊法治法 14184 综合医书 86413
本草方书 144993 临证各科 56872
针灸推拿灸 31009 中医问诊 76765
伤寒金匮 29602 预防养生 13836

2.模型训练

2.1实验参数

超参数 继续预训练 有监督微调 DPO优化
learning_rate 6e-5 3e-4 3e-4
lora_rank 16 16 16
num_train_epochs 5 6 6
per_device_train_batch_size 8 16 8
per_device_eval_batch_size - 16 -
gradient_accumulation_steps 4 4 4
lr_scheduler_type cosine cosine cosine
logging_steps 1000 100 100
save_steps 2000 5000 2000
train_loss 1.456 1.245 0.00276
eval_loss - 1.258 -

2.2模型下载-LoRA权重

模型名称 大小 下载地址
pretrain 25.05MB 下载地址
sft 25.05MB 下载地址
dpo 50.16MB 下载地址

3.模型效果

应用示例 Input 通义千问 Huang-Di
医经理论 中医古籍中的“阴虚之热”和“阴盛格阳之火”分别应该如何进行治疗? 很抱歉呀,目前我还没有掌握回答这个问题的技巧,我会不断学习,您也可以问我一些其他问题,我会努力给您解答。 在中医中,“阴虚之热”是由于人体内阴气不足、水液不行、真阴耗伤等原因所致的虚火症状。对于阴虚之热的治疗,建议采用滋补阴液的方法,如六味地黄丸等中药进行治疗,能够缓解症状,达到滋阴降火的效果。而“阴盛格阳之火”则是指人体内阳气不足、阴气过盛、火热亢盛等导致的虚火症状。对于阴盛格阳之火的治疗,建议采用温补阳气的方法进行治疗,如附子干姜肉桂等中药能够温补阳气,从而产生脾胃运化之功,消除虚火症状。总的来说,中医认为实火宜泻、虚火宜补,从病因和症状分析,采用不同的治疗方法,有利于恢复人体内阴阳平衡,达到疾病的治疗和康复效果。
诊法治法 如何判断一个人阴阳失调? 中医认为,阴阳失调是指人体内阴阳两种力量的失衡,通常表现为身体的不适和疾病。可以通过观察一个人的症状来判断其是否阴阳失调,例如:体温过高或过低、头痛、口干、口臭、腹泻、便秘、失眠、多梦、焦虑、抑郁等。另外,可以通过检查一个人的脉搏和舌苔来进一步判断其阴阳失调的情况。 阴阳失调是中医理论中的重要概念,它指的是身体内部阴阳两种力量之间的不平衡。通常来说,判断一个人是否存在阴阳失调需要从以下几方面入手:1.观察外貌特征:阴虚、阳盛型的患者通常容易出现脸色苍白、瘦弱等症状;而阳虚、阴盛型的患者则可能表现为面色红润、肥胖等情况。2.检查舌苔:中医认为舌质和舌苔可以反映脏腑功能的变化。例如,阴虚型患者的舌质可能较薄,舌苔可能较厚;而阳虚型患者的舌质可能较厚,舌苔可能较薄。3.询问病史:了解患者过去是否有过类似疾病或者长期服用某些药物也可以帮助诊断阴阳失调。4.检测体温:阴虚型患者往往会出现低热、寒战等症状,而阳虚型患者则可能出现高热、汗多等情况。
针灸推拿灸 古籍中九刺是什么? 九刺是中医针灸学中的一种治疗方法,也称为“九针”。九刺是指使用九根针具,通过不同的刺激方式和手法,对特定的穴位进行刺激,以达到治疗疾病的目的。九刺的应用范围非常广泛,可以用于治疗各种疾病,如肩周炎、腰痛、坐骨神经痛、痛风等。 九刺指的是中医中古代的九种刺法,分别是输刺、远道刺、经刺、络刺、分刺、大泻刺、毛刺、巨刺、淬刺。在《灵枢经》的《官针》条中就提到:“凡刺有九,以应九变”,也就是说,九种不同的刺法适用于不同的病变。其中,输刺是通过针刺使病邪随汗液排出;远道刺是在病变部位远处刺激;经刺是通过刺激经脉穴位将病邪排出;络刺是刺激络脉,达到类似经刺的效果;分刺是将病变部位分成若干小部分进行刺激;大泻刺是快速放血,将有毒血液排出体外;毛刺是将针头刺入皮肤表层,产生微刺激;巨刺则是在病变部位直接刺出大量污血;淬刺是将针先烤热后再刺激穴位。九刺的运用需要根据不同的疾病情况灵活选择,是中医治疗的重要手段之一。
中医问诊 最近我怀孕了,但是感到神疲无力、面色黯淡、腰酸腹痛并且出现阴道流血的症状,这是否属于血虚滑胎的情况?请问怎样治疗? 首先,我要强调的是,我不是医生,我只能提供一些建议。如果你怀孕期间出现这些症状,请务必咨询专业的医生。血虚滑胎是中医中的一个概念,通常是指孕妇血液不足,导致胎儿不稳定,容易流产。但是,出现这些症状并不一定就是血虚滑胎,可能是其他原因引起的。因此,建议你及时就诊,让医生进行详细检查,确诊原因。在确诊后,医生会根据具体情况给出相应的治疗方案。对于血虚滑胎的治疗,中医可能会采用补血、安胎的方法,比如服用一些补血的药物,如当归、红枣、枸杞等。此外,还需要注意休息,保持良好的生活习惯,避免过度劳累,保持情绪稳定等。总之,如果你怀孕期间出现这些症状,请务必及时就诊,让专业的医生进行诊断和治疗。希望你和宝宝都健康平安! 是的,你的症状很可能是血虚滑胎引起的。血虚滑胎是指由于孕妇平时血虚及有滑胎病史,怀孕后阴血益虚导致胎儿失去滋养而引起的情况。其中症状包括神疲无力、面色淡黄、浮肿、腰酸腹痛,而且严重的话还会出现阴道流血和胎动欲坠等症状。对于血虚滑胎的治疗,应该以补血益气为主,以此来预防滑胎的发生。常用的中药方剂包括胎元饮,该方剂的组成包括人参、当归、杜仲、芍药、熟地、白术、陈皮、炙甘草等,可起到补气补血的作用。如果出现下血症状,还可以加入阿胶和艾炭等药物。需要强调的是,血虚滑胎的治疗应该遵循个性化的原则,应根据孕妇的具体情况而定。另外,饮食调理、心理调节等方面的治疗也非常重要,这些措施可以有效地提高孕妇血液的养分含量,提高孕妇免疫力,从而预防或缓解血虚滑胎产生的症状。如果出现滑胎症状,建议及时就医,并且采取综合治疗。"希望我的回答能够对你有所帮助。如果你还有其他问题,请随时向我提问。祝你健康快乐!

更多优质内容请关注公号:汀丶人工智能;会提供一些相关的资源和优质文章,免费获取阅读。

  • 参考链接

1.
https://huggingface.co/IDEA-CCNL/Ziya-LLaMA-13B-v1

2.
https://github.com/ymcui/Chinese-LLaMA-Alpaca

3.
https://github.com/huggingface/peft

一、什么是策略模式

定义一些列算法类,将每一个算法封装起来,并让它们可以互相替换。

策略模式让算法独立于使用它的客户而变化,是一种对象行为型模式。

以上是策略模式的一般定义,属于是课本内容。

在没有真正理解策略模式之前并不需要对此定义下过多功夫,读一遍直接进入下一章节。

二、为什么要用策略模式

我们应该知道,所谓的设计模式实际上是一种
经过检验的、科学高效的、针对某种场景的最佳编程设计实践

所以要理解某一种设计模式,就必须知道我们什么时候可以用,用之前和用之后到底有什么区别。

练习:

假设现在要设计一个贩卖各类书籍的电子商务网站的购物车系统。

一个最简单的情况就是把所有货品的单价乘上数量,但是
实际情况肯定比这要复杂

比如,本网站可能对所有的高级会员提供每本20%的促销折扣;对中级会员提供每本10%的促销折扣;对初级会员没有折扣。

根据描述,折扣是根据以下的几个算法中的一个进行的:

算法一:对初级会员没有折扣。

算法二:对中级会员提供10%的促销折扣。

算法三:对高级会员提供20%的促销折扣。

给出一本图书,如300元,若是高级会员,则输出价格为240元。

针对以上场景,大多数的我们写的代码就是使用的if...else...。

我们先提前揭晓,这种场景下就是我们使用策略模式的最佳时机。

那在我们尝试使用策略模式改进代码之前,我们必须要问:

if...else到底有什么问题?

传统实现方式

public Double calculationPrice(String type, Double originalPrice, int n) {

    //中级会员计费
    if (type.equals("intermediateMember")) {
        return originalPrice * n - originalPrice * 0.1;
    }
    //高级会员计费
    if (type.equals("advancePrimaryMember")) {
        return originalPrice * n - originalPrice * 0.2;
    }
    //普通会员计费
    return originalPrice;
}

这种编码方式到底差在哪?

大佬告诉我们说:
维护性差。

什么叫维护性差?

就是下次你想加个超级黄金vip会员,以及各种后续会员种类,你就要不断往里加if...else...,这就违反了
开闭原则

这里又有另外两个问题:

什么是开闭原则?我凭啥要遵守开闭原则?

什么是开闭原则?

开闭原则比较好记忆,顾名思义:

对扩展开放,对修改关闭。

大意就是你想改东西,不要改原代码,而是进行扩展代码。

为什么要遵守开闭原则(以及各种乱七八糟的原则)?

简单直接一点就是,这些原则都是巨佬们总结出来的,你如果不懂,你就直接选择相信就好了。

解释一下就是:

系统随着开发的不断进展,需求不断增多,代码越来越长,如果没有合理框架的制约那就只能沦为一个扩展难、维护难的屎山。

所以我们遵守开闭原则就是说需要一个科学合理的框架规范我们的系统熵增,在不修改原代码的基础上让系统拥有灵活性和稳定性。

一句话,上面的代码直接修改原代码,时间久了系统只会沦为屎山。

那怎么使用策略模式改造,而遵守开闭原则呢?

策略模式如何实现

Strategy(抽象策略类)

它为所支持的算法声明抽象方法,是所有策略类的父类。它可以使抽象类或者具体类,也可以是接口。

public interface MemberStrategy {
    // 一个计算价格的抽象方法
    //price商品的价格 n商品的个数
    public double calcPrice(double price, int n);
}

ConcreteStrategy(具体策略类)

它实现了上面抽象策略类的抽象方法。

在实际运行中,这个具体的策略类将会代替在**环境类(Context)**中定义的抽象策略类对象最终执行不同的实现逻辑。

可以看到下面的代码中,三种不同的策略类实现了同一个抽象策略类,每种策略对应一种实现,分别应对一个业务处理方式。

// 普通会员——不打折
public class PrimaryMemberStrategy implements MemberStrategy { // 实现策略
    @Override
    public double calcPrice(double price, int n) {
        return price * n;
    }
}

// 中级会员 打百分之10的折扣
public class IntermediateMemberStrategy implements MemberStrategy{
    @Override
    public double calcPrice(double price, int n) {
        double money = (price * n) - price * n * 0.1;
        return money;
    }
}

// 高级会员类 20%折扣
public class AdvanceMemberStrategy implements MemberStrategy{
    @Override
    public double calcPrice(double price, int n) {
        double money = price * n - price * n * 0.2;
        return money;
    }
}

Context(环境类)

这个对我来说一开始很难理解。

主要是不能理解
Context
这个词在这里的意思,再加上网上一大堆直接翻译为“上下文”的文章博客,我直接吐了:

我不理解Context的意思,难道就能理解“上下文”的意思?

还有类似的:事务又是什么东西?

所以我直接不管这个Context是什么东西,直接看代码。

首先他是一个类,我们看这个类里有什么。

一个成员变量memberStrategy

一个构造方法

一个计算价格的方法,内容返回memberStrategy的calcPrice方法

往下看。

/**
 * 负责和具体的策略类交互
 * 这样的话,具体的算法和直接的客户端调用分离了,使得算法可以独立于客户端独立的变化。
 */

// 上下文类/环境类
public class MemberContext {
    // 用户折扣策略接口
    private MemberStrategy memberStrategy;

    // 注入构造方法
    public MemberContext(MemberStrategy memberStrategy) {
        this.memberStrategy = memberStrategy;
    }

    // 计算价格
    public double qoutePrice(double goodsPrice, int n){
        // 通过接口变量调用对应的具体策略
        return memberStrategy.calcPrice(goodsPrice, n);
    }

}


接下来看测试类中 Context 类的使用是什么样子的。

    // 测试类
    public class Application {
        public static void main(String[] args) {

            // 具体行为策略
            MemberStrategy primaryMemberStrategy = new PrimaryMemberStrategy(); // 接口回调(向上转型)
            MemberStrategy intermediateMemberStrategy = new IntermediateMemberStrategy();
            MemberStrategy advanceMemberStrategy = new AdvanceMemberStrategy();

            // 用户选择不同策略
            MemberContext primaryContext = new MemberContext(primaryMemberStrategy);
            MemberContext intermediateContext = new MemberContext(intermediateMemberStrategy);
            MemberContext advanceContext = new MemberContext(advanceMemberStrategy);

            //计算一本300块钱的书
            System.out.println("普通会员的价格:"+ primaryContext.qoutePrice(300,1));// 普通会员:300
            System.out.println("中级会员的价格:"+ intermediateContext.qoutePrice(300,1));// 中级会员 270
            System.out.println("高级会员的价格:"+ advanceContext.qoutePrice(300,1));// 高级会员240
        }
    }

发现了什么?

Context都是被new出来的,new的时候传入的Strategy实现类全部不一样,你传的不一样,将来context.calcPrice()执行的逻辑就不一样。

懂了没有?

什么是上下文?

什么是Context?

就是随机应变,像变色龙一样随着不同的环境变化而自由变化。

开发者根据“上下文”不同的业务需求往Context里面放置不同的Strategy。

这就是Context上下文的意思。

这里的Strategy可以你自己new,你也可以把它放在配置类里面配置,然后在代码中读取,这样更加灵活方便。

三、使用策略模式的场景总结

那我们知道了策略模式怎么实现,也就是已经有了一把锤子在手上了,
那什么时候用这把锤子呢?

1. 系统中需要动态地在几种算法中选择一种。

2. 一个对象有很多的行为,如果不用策略模式就只能用一大堆的if...else...来实现。

3. 不希望客户端知道复杂的、与算法相关的数据结构。在具体策略类中封装算法和相关的数据结构,提高算法的保密性与安全性。

然后我们用一些实际的例子来理解策略模式的大概的使用场景:

1.
支付方式选择:
假设平台支持多种支付方式,比如微信、支付宝、银行卡等。

2.
数据渲染方式:
如果你有一个应用程序,它可以以多种格式输出数据,比如XML、JSON或CSV。

3.
导航策略:
导航应用多种路径计算方法,如最快路线、最短路线、避开收费路线等。

4.
压缩数据:
根据不同的情况(比如压缩率、速度等)使用不同的压缩算法(如ZIP、RAR、7z等)。

四、策略模式有什么好处

1. 完美支持了开闭原则。

2. 通过抽象算法和继承实现,避免了大量重复代码。

3. 避免了多重选择语句(硬编码,不易维护)。


往期推荐:


师爷,翻译翻译什么叫AOP


翻译,师爷师爷什么叫事务


纪念JDBC


SpringBoot实现动态数据源配置


聚簇索引、回表与覆盖索引


Java锁到底是个什么东西

前言

已经正式从NET转型JAVA。今后开始多写一点JAVA相关的文章。

因为已经正式转Java了,所以,对于Java的一些判断,应该就比以前更准确了。总得来说,java有好的东西,有不好的东西,就语言本身和java的常用组件来讲,并不能判断,java比其他语言高一个档次,当然,也不会低一个档次。应该跟其他语言是一个段位的。

但java的调试,确实是比较花费时间,他做不到编译成功后,就能运行成功。这里有注解的问题,有maven的问题,有组件版本的问题。总之,检测的非常不好,非常浪费时间。

java的好处就是,团队成员比较多,毕竟开发起来真的很废人。但好处也在这里,人多,代表着,1,大家的压力都不大,人多压力就会分散。2,功能和性能有时间做的更优秀,人多就是工时多。

而且Java工资确实相对比其他语言高。

总体来说,java是比较幸福的。

开始正文

Aspectj提供一种在字符串里编程的模式,即在字符串里写函数,然后程序启动的时候会动态的把字符串里的函数给执行了。

例如:

"execution(* *(..))"

这里的execution就是一个函数,我们调用它,然后传递的参数是【* *(..)】。

Aspectj 使用
使用前,我们先了解一下execution和它的参数的匹配规则:
execution: 用于匹配方法执行的连接点;
execution(
public * *(..)) ==> 匹配所有目标类的public方法,第一个*代表返回类型,第二个*代表方法名,而..代表任意入参的方法。
execution(
* com.oysept.springboot.controller..*.*(..)) ==>该包及所有子包下任何类的任何方法。
execution(
* com.oysept.springboot.controller.*(..)) ==>该包下任何类的任何方法。
execution(
* com.oysept.springboot.controller.AspectJController.*(..)) ==>该包下AspectJController类的任何方法。
execution(
* com..*.*Controller.method*(..)) ==>匹配包名前缀为com的任何包下类名后缀为Controller的方法,方法名必须以method为前缀。
execution(
* *To(..)) ==>匹配目标类所有以To为后缀的方法。
注: 该方法只是为了声明一个公共的环绕通知,也可以直接在具体方法配置,如: @Around(
"execution(* com.oysept.springboot.controller..*.*(..))")

@Before和@AfterReturning

然后我们编写一个aspect的基础使用代码,如下:
/*** @Before:定义了前置通知方法。打印出入参
* @AfterReturning:定义了后置返回通知方法。打印出入参、返参
*/@Slf4j
@Aspect
@Component
public classAopAspect_Basic {@Before("execution(public * com.k.tender.controller.business.user.UserController.*(..))")public voiddoBefore(JoinPoint point){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" +args);
}

@AfterReturning(value
= "execution(public * com.k.tender.controller.business.user.UserController.*(..))", returning = "returnValue")public voiddoAfterReturning(JoinPoint point, Object returnValue){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" + args + ",返回值为:" +returnValue);
}

}

如上代码,我们使用了@Before和@AfterReturning注解,在UserController调用前和后,分别埋了点,并输出了函数的入参和出参。

@Pointcut

@Pointcut其实是一个提取
execution函数的操作,就是指定一个埋点,然后使用
了@Before和@AfterReturning注解时,就不用每次都写那个
execution函数了,这样就不用担心写错了。
代码示例如下:
  @Pointcut("execution(public * com.k.tender.controller.business.tender.TenderController.*(..))")public voiddoPointCut() {

}

@Before(
"doPointCut()")public voiddoBefore(JoinPoint point){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" +args);
}

@AfterReturning(value
= "doPointCut()", returning = "returnValue")public voiddoAfterReturning(JoinPoint point, Object returnValue){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" + args + ",返回值为:" +returnValue);
}

对注解埋点

有时候,我们希望编写一个注解,然后让有该注解的函数,都被拦截,那么就可以使用Aspectj的注解埋点模式。

代码如下:

@Slf4j
@Aspect
@Component
public classAopAspect_Annotation {

@Before(
"@annotation(com.k.tender.aop.MyAop)")public voiddoBefore(JoinPoint point){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" +args);
}
@AfterReturning(value
="@annotation(com.k.tender.aop.MyAop)", returning = "returnValue")public voiddoAfterReturning(JoinPoint point, Object returnValue){
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" + args + ",返回值为:" +returnValue);
}
}

public @interface MyAop {
String value() default "自定义注解拦截";
}

如果觉得写注解的命名空间麻烦,也可以这样写:

@Before("@annotation(apiOperation)")public voiddoBefore(JoinPoint point, MyAopAsyncTask apiOperation) {
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" +args);
}

还可以将注解和前面的excution函数结合写:

  @Before("execution(public * com.k..*.*(..)) && @annotation(apiOperation)")public void doBefore(JoinPoint point, MyAopAsyncTask apiOperation) throwsNoSuchMethodException {
String methodName
=point.getSignature().getName();
List
<Object> args =Arrays.asList(point.getArgs());
log.info(
"调用前连接点方法为:" + methodName + ",参数为:" +args);
}

有时候我们的拦截会触发多次,这个具体原因调查起来很麻烦,我们也可以这样解决,代码如下:

 private volatile long hashcode = 0;//应对重复触发
    @Before("execution(public * com.k..*.*(..)) && @annotation(apiOperation)")public void doBefore(JoinPoint point, MyAopAsyncTask apiOperation) throwsNoSuchMethodException {if (hashcode !=point.getTarget().hashCode()) {
log.info(
"========doBefore========");
ServletRequestAttributes requestAttributes
=(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request
=requestAttributes.getRequest();
String method
=request.getMethod();}

}

使用hashcode来过滤多次拦截。

----------------------------------------------------------------------------------------------------

到此,Android里使用AspectJ实现AOP就介绍完了。

----------------------------------------------------------------------------------------------------

注:此文章为原创,任何形式的转载都请联系作者获得授权并注明出处!
若您觉得这篇文章还不错,请点击下方的

推荐】

,非常感谢!

https://www.cnblogs.com/kiba/p/18027435