2024年1月

死锁是多线程编程中常见的问题,它会导致线程相互等待,无法继续执行。在Java中,死锁是一个需要注意和解决的重要问题。让我们通过一系列详细的例子来深入了解Java死锁的现象和解决方法。

1. 什么是死锁?

死锁是指两个或多个线程在互相等待对方释放锁资源的情况下,导致程序无法继续执行的现象。这通常发生在多个线程同时持有不同锁,并尝试获取对方已持有的锁。

2. 简单的死锁示例

考虑两个线程分别尝试获取两个不同的锁:

public class DeadlockExample {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void method1() {
        synchronized (lock1) {
            // 一些代码逻辑

            synchronized (lock2) {
                // 一些代码逻辑
            }
        }
    }

    public void method2() {
        synchronized (lock2) {
            // 一些代码逻辑

            synchronized (lock1) {
                // 一些代码逻辑
            }
        }
    }
}

如果线程1调用
method1
,同时线程2调用
method2
,它们可能会陷入相互等待对方释放锁的状态,导致死锁。

3. 死锁的检测和解决

为了检测死锁,可以使用工具如jstack。然后,为了解决死锁,我们可以采取以下方法之一:

  • 锁的顺序:
    确保所有线程以相同的顺序获取锁。
  • 超时机制:
    在获取锁的过程中设置超时,如果超时则放弃锁。
  • 使用Lock接口:
    使用
    ReentrantLock

    java.util.concurrent
    包中的锁,它们支持更灵活的锁定机制。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class DeadlockSolutionExample {
    private final Lock lock1 = new ReentrantLock();
    private final Lock lock2 = new ReentrantLock();

    public void method1() {
        lock1.lock();
        try {
            // 一些代码逻辑

            lock2.lock();
            try {
                // 一些代码逻辑
            } finally {
                lock2.unlock();
            }
        } finally {
            lock1.unlock();
        }
    }

    public void method2() {
        lock2.lock();
        try {
            // 一些代码逻辑

            lock1.lock();
            try {
                // 一些代码逻辑
            } finally {
                lock1.unlock();
            }
        } finally {
            lock2.unlock();
        }
    }
}

通过使用
ReentrantLock
,我们可以更灵活地控制锁的获取和释放,并通过
tryLock
等方法设置超时。

结语

死锁是多线程编程中的一个复杂问题,但通过谨慎的设计和使用合适的工具,我们可以有效地避免和解决死锁问题。希望这篇博文对你理解Java死锁及其解决方法有所帮助。如有疑问,请随时提问。

一、简介
我曾看到过许多开发人员使用错误的工具来分析问题,更有甚者,有些人连任何工具都没有使用。他们采取的分析方法通常包括:输出更多的调试信息,或者做一些临时性的代码审查。这里的临时性是指,通过猜测来推断问题可能来之哪个部分的代码。有时候,开发人员会幸运的发现问题刚好处于他们正在审查的代码当中。然而,在更多情况下并非如此,问题发生的位置和表现出来的位置往往相差甚远。说起来有点可笑,我很长一段时间都是这样调试的。自从我接触了《Net 高级调试》,调试原来不是这样的。通过使用一些功能强大的调试工具,开发人员可以极大的减少在分析问题时耗费的时间。
所以,我们必须学会如何使用工具进行调试,要知道每种工具在哪些情况下使用以及如何使用。这是对大家的建议,更多是对我自己的鞭策。今天我们就介绍一些调试中经常要用到的一些工具。
在开始之前,我还得说明一下,《Net 高级调试》这本书,写的挺早的,中文翻译的版本是2011年机械工业出版社出版的,里面的内容有些也比较老旧,我都做了实时的更新,都是最新的。因为这次只是工具介绍,具体的环境就不做说明了。还有,有些工具,比如:NTSD、KD等类似的命令行调试器我就不太在行了,所以我会使用在这个系列出现的GUI调试工具(Windbg)来调试问题,具体其他调试器使用方法,大家可以自行补充。

二、常用调试工具
我们想要成为高级程序员,高级的调试技巧必须掌握,当然,这些技巧需要借助工具来体现,我们下面就来介绍一下将在我这个系列里面用到的一些调试工具,当前不是全部,我会在以后得章节里,有需要在介绍一些用到的调试工具。
说起调试工具,其实有很多,他们的用途也不一样,有一部分工具侧重分析某一类特定的问题,而有些工具可以同时处理若干类问题,我们要知道每种工具在什么情况下使用以及如何使用,这一点非常重要。

1、Windows 调试工具集
Windows 调试工具集是一个免费的软件包,它有两个版本:32位和64位。在这个工具集中有3种用户态调试工具:
NTSD(Microsoft NT 符号调试器)、CDB(Microsoft 控制台调试器)
和 WinDbg,以及
一种内核态调试器 KD
。我说一下区别:NTSD 和 CDB(KD 它属于内核态调试器,但是它是命令行调试器)都是命令行调试器,Windbug 是一个具有界面的调试器,它可以更容易调试源代码。他们虽然有这些区别,但是他们使用了相同的内核调试器引擎。
【应用场合】一组调试器和调试工具。
【下载地址】
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/debugger-download-tools
【软件版本】Microsoft (R) Windows Debugger Version 10.0.22621.2428(书上:6.8.4.0)
Windebug效果如图:

NTSD如图:

我们安装了调试器工具集,也要配置环境变量,就不用每次使用都切换目录。

可以将 Windows 调试工具作为开发工具包的一部分或独立工具集获取:
a、作为 WDK 的一部分
Windows 调试工具包含在 Windows 驱动程序工具包 (WDK) 中。 若要获取 WDK,
请参阅下载 Windows 驱动程序工具包 (WDK)

b、作为 Windows SDK 的一部分
Windows 调试工具包含在 Windows 软件开发工具包 (SDK) 中。 若要下载安装程序或 ISO 映像,请参阅 Windows 开发人员中心上的
Windows SDK。
c、作为独立工具集
你可以单独安装适用于 Windows 的调试工具,而无需 Windows SDK 或 WDK,方法是开始安装 Windows SDK,然后在功能列表中选择“ Windows 调试工具” 以安装 (并清除) 选择所有其他功能。 若要下载安装程序或 ISO 映像,请参阅 Windows 开发人员中心上的
Windows SDK


我使用的Windows SDK 版本的安装,Windows SDK (10.0.22621) for Windows 11,版本 22H2 (2023 年 10 月更新) 提供了用于生成 Windows 应用程序的最新标头、库、元数据和工具。 使用此 SDK 为Windows 11版本 22H2 和早期 Windows 版本生成通用 Windows 平台 (UWP) 和 Win32 应用程序。
Windows SDK 官网下载地址:
https://developer.microsoft.com/zh-cn/windows/downloads/windows-sdk/


2、Net 8.0 可再发行组件和 Net 8.0 SDK
刚开始说起【可再发行组件】,我是一头懵,不知道是什么。知道了是什么东西,也很容易,只是我们不这样说,叫它“可再发行组件”。【Net 8.0 可再发行组件】就是 Net 8.0 Runtime 运行时的组件。
【应用场合】Net 运行时(.Net Runtime)
【下载地址】
https://dotnet.microsoft.com/zh-cn/download/dotnet/8.0
【组件版本】8.0(书上是:2.0)
点击下载地址,我们就可以进入下载页面,当然,随着时间的推移,这个版本也是会发生变化的。这个页面左侧是 Net 8.0 SDK ,右侧页面就是各种的 Runtime,有我们经常用于运行 Web 页面:ASP.NET Core 运行时 8.0.0,运行桌面程序的:NET 桌面运行时 8.0.0,等等很多。
运行时和SDK 的区别就是,【运行时】就是一个Net 运行基础的、必须的环境,很干净,包括所有的框架程序集以及 Net 运行时的二进制文件,一点多余的东西都没有。
SDK 是包含【运行时】的全部功能,只有SDK 才能使开发人员编写新的 Net 应用程序,因为它提供了所有必要的工具(编译器、汇编器、构建工具)及其库等。Net SDK 并不提供基于图形界面的集成开发环境,而只是提供了一个基于命令行的环境。我们一般选择在服务器上安装更干净的【运行时】,在开发平台选择安装 SDK。
效果如图:


3、SOS
SOS名称的来历:SOS 不是求救的意思,当 Net 框架哈仔 1.0 阶段时,Microsoft 团队使用了一个叫【STRIKE】的调试器扩展来分析 Net 代码中的各种复杂问题。当 Net 框架日趋成熟时,这个调试器扩展的名称也就逐渐演变成了“Son of Strike”(SOS)。
用于调试 Net 应用程序的扩展组件。SOS 是一个调试器扩展,用于调试 Net 应用程序,它提供了一组非常丰富的命令,这些命令使开发人员可以对 CLR进行深入分析,并且有助于找出应用程序中各种复杂错误的原因。当然,它还有一些命令可以用来查看【终结队列】、【托管堆】、【托管线程】,在托管代码中设置断点以及查看异常等。
由于SOS 能够提供 CLR 内部工作机制的抽象视图,因此在使用SOS济宁调试时,必须使用正确版本的SOS。每个版本的 Net 在发布时都带有相应的SOS,可以在以下位置找到:
%windir%\Microsoft.NET\<architecture>\<version>\sos.dll

architecture:可以是 Framework(32位)或者 Framework(64位),而 version 可以表示所使用的 Net 框架的版本。
C:\Windows\Microsoft.NET\Framework\v4.0.30319\
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\

32位 SOS 效果如图:

64位 SOS 效果如图:


Windbg Preview 是不用单独执行加载的工作的,它会自动加载它所需要的版本,如果是老版本的 Windbg,比如:windbg10 ,可以通过 .load 命令加载 SOS.dll。一般情况,使用windbg自带的命令【.load sos】即可自动加载,使用【.chain】查看加载是否成功。
我们执行【.chain】命令,看一下执行效果,说明一下,要想执行这个命令,我们要创建一个项目,通过 Windbg 加载我们的 EXE 项目,通过【g】命令运行程序,就可以执行命令了,否则看不到 SOS。效果如图:


4、SOSEX
我们在上面说了SOS.DLL,这里我们在说说 SOSEX.DLL,它也是用于调试 Net 应用程序的扩展组件,可以用于非托管代码和托管代码的调试,他是有 Steve Johnson 开发的,SOSEX表示SOS Extended。SOSEX 增加了一组功能强大的调试命令,这些命令包含【死锁检测】、基于代的垃圾收集命令以及其他一些功能强大的断点命令。
SOSEX的安装包只是一个ZIP文件,只需将 ZIP文件中压缩的文件释放到指定为止即可完成安装。
但是需要说明一下,它虽然很强大,但是只能在 Net Framework 平台上使用,我们最新的跨平台是不能使用的。太可惜了。
如果大家需要调试 Net Framework 版本的程序,它还是很有用的,大家可以去网上自行下载。
我的下载地址:
https://www.xiazaila.com/soft/46338.html

我先给大家一个印象吧,有一个直观的感觉,截图如下:


5、CLR分析器
CLR 分析器:堆内存分配情况进行分析。在调试 Net 应用程序中与内存相关的问题时,CLR分析器是一种非常有用的工具。它可以提供的功能:堆的统计信息、垃圾收集操作的统计信息、垃圾收集器句柄统计信息、垃圾收集中每代对象的总体大小、性能分析的统计信息。
CLR分析器的安装过程很简单,安装包是一个 ZIP文件,只需将 ZIP 文件的内容释放到指定的文件夹即可完成安装。它也是分为两个版本:32位的和64位的,要启动 CLR 分析器,只需运行 CLRProfiler.exe,效果如图:

运行效果如图:


官网下载地址:
https://github.com/microsoftarchive/clrprofiler/releases

6、性能计数器
在安装 Net 框架的同时会安装一组性能计数器,我们可以通过 Windows 性能监视器(Windows Performance Monitor)来查看这些性能计数器。这些性能计数器包含:
异常(.NET CLR Exceptions):与 Net 应用程序中抛出的异常相关的性能计数器。
互用性(NET CLR Interop):与 Net 应用程序中对 COM,COM+以及外部库等使用情况相关的性能计数器。
即时编译(NET CLR Jit):与 即时编译【JIT】相关的性能计数器。
加载器(NET CLR Loading):与加载 Net 各种实体【例如程序集,类型等】相关的性能计数器。
锁和线程(NET CLR LocksAndThreads):与线程以及锁定行为相关的性能计数器。
内存(NET CLR Memory):与垃圾收集器和内存使用相关的性能计数器。
网络(NET CLR Networking/NET CLR Networking 4.0.0.0):与 Net 应用程序在网络上的发送和接受等操作相关的性能计数器。
远程行为(NET CLR Remoting):与 Net 应用程序中的远程对象相关的性能计数器。
安全(NET CLR Security):与 Net 框架执行的安全检查相关的性能计数器。
我们可以在开始菜单里面找到他们,依次【Windows 管理工具】---》【性能监视器】,效果如图:

我们打开【性能监视器】,操作界面如下:

我们可以点击绿色的加号,就能看到性能计数器的类别,如图:


7、Net 反编译器
如果我们想查看已经编译后文件的源代码,怎么办呢?立刻我们就会想到反编译工具,这种编译器可以从 MSIL中生成各种高级语言代码。这种工具不少,比如以前用的比较多的 Reflector.exe,但是,相对于现在来说还是使用感觉比较差。我在这里推荐 ILSpy和 DnSpy,他们两个都可以查看元数据、IL代码,不同点就是 DnSpy 可以像 Visual Studio 那样编辑和调试代码,功能很强大。
【应用场合】ILSpy 可以反编译源码,DnSpy 可以反编译源码和调试源码。
【下载地址】ILSpy官网下载地址:
https://github.com/icsharpcode/ILSpy
DnSpy官网下载地址:
https://github.com/dnSpy/dnSpy
【软件版本】ILSpy 版本:7.2.1.6856,DnSpy 版本:
6.1.8.0

ILSpy 操作界面截图如下:

DnSpy操作界面截图如下:


8、PowerDbg
PowerDbg 是由 Roberto Farah 开发的一个库,它使开发人员通过 Powershell 来控制非托管调试器。当希望通过命令行来控制调试器的运行时,它是一个非常有用的工具,易于扩展,并且能够对常用的命令进行调用和格式化。
PowerDbg的工作原理是向运行中的 WinDbg 发送各种命令,任何命令在执行时,将首先打开一个日志文件,接下来在执行命令,最后关闭日志文件。PowerDbg 脚本通过这个日志文件来分析结果和产生输出信息。
我个人不太推荐,使用繁琐,全部是命令行,不是所见即所得的,不方便实用,还是不如直接使用 Windbg 来的更快,如果大家想试试的,也是个好事。

官网下载地址:
https://www.softpedia.com/get/Programming/Components-Libraries/PowerDbg.shtml

9、托管调试助手
托管调试助手(MDA)用于一般性的 CLR 调试,它并不是一个独立的工具,而是 CLR 中的一个组件,在运行和调试 Net 应用程序是将提供各种有用的信息。通过对【运行时】加你选哪个监测,可以找出一些常见的编程错误并且能在发布应用程序之前修复它们。MDA 可以分为几类:
非托管互用性:与非托管互用性问题相关的调试助手。
非托管互用性(COM):与 COM 非托管互操作问题相关的调试助手。
非托管互用性(P/Invoke):与平台调用非托管互用性问题相关的调试助手。
加载器:CLR 加载器相关的调试助手。
线程:与线程问题相关的调试助手。
BCL:与基础类库问题相关的调试助手。
其他:其他调试问题。
如果大家想了解 MDA 更多的内容,可以直接去微软的网站上去查看。以下是网址:
https://learn.microsoft.com/zh-cn/dotnet/framework/debug-trace-profile/diagnosing-errors-with-managed-debugging-assistants
。这里面说的很清楚,直接学习就可以了。

三、总结
新的开始,新的征程。我从今天开始要写一个新的系列,这个系列完全是按着《Advanced .Net Debugging》这本书的节奏和章节来的,当然了,有的内容过于老旧还是会有所删减,但是质量不会降低。我写完上一个高级调试的系列,收货还是挺丰富的。那个系列可以带我入门,如果想真的有所提升,还是需要将整本书重新研读。估计这个系列的路也不一定好走,还好我喜欢编程,喜欢知其然,也要做到知其所以然,有这样的决心支持我走下去。不忘初心,继续努力吧。

前言

本来是想发 next.js 开发笔记的,结果发现里面涉及了太多东西,还是拆分出来发吧~

本文记录一下在 TypeScript 项目里封装 axios 的过程,之前在开发 StarBlog-Admin 的时候已经做了一次封装,不过那时是 JavaScript ,跟 TypeScript 还是有些区别的。

另外我在跟着 next.js 文档开发的时候,注意到官方文档推荐使用
@tanstack/react-query
来封装请求类的操作,浅看了一下文档之后感觉很不错,接下来我会在项目里实践。

定义配置

先创建一个 global 配置,
src/utilities/global.ts

export default class Global {
  static baseUrl = process.env.NEXT_PUBLIC_BASE_URL
}

这是在 next.js 项目,可以用 next 规定的环境变量,其他项目可以自行修改。

封装 auth

认证这部分跟 axios 有点关系,但关系也不是很大,不过因为 axios 封装里面需要用到,所以我也一并贴出来吧。

创建
src/utilities/auth.ts
文件

/**
 * 登录信息
 */
export interface LoginProps {
  token: string
  username: string
  expiration: string
}

/**
 * 认证授权工具类
 */
export default abstract class Auth {
  static get storage(): Storage | null {
    if (typeof window !== 'undefined') {
      return window.localStorage
    }
    return null
  }

  /**
     * 检查是否已登录
     * @return boolean
     */
  public static isLogin() {
    let token = this.storage?.getItem('token')
    let userName = this.storage?.getItem('user')

    if (!token || token.length === 0) return false
    if (!userName || userName.length === 0) return false
    return !this.isExpired();
  }

  /**
     * 检查登录是否过期
     * @return boolean
     */
  public static isExpired = () => {
    let expiration = this.storage?.getItem('expiration')
    if (expiration) {
      let now = new Date()
      let expirationTime = new Date(expiration)
      if (now > expirationTime) return true
    }

    return false
  }

  /**
     * 读取保存的token
     * @return string
     */
  public static getToken = () => {
    return this.storage?.getItem('token')
  }

  /**
     * 保存登录信息
     * @param props
     */
  public static login = (props: LoginProps) => {
    this.storage?.setItem('token', props.token)
    this.storage?.setItem('user', props.username)
    this.storage?.setItem('expiration', props.expiration)
  }

  /**
     * 注销
     */
  public static logout = () => {
    this.storage?.removeItem('token')
    this.storage?.removeItem('user')
    this.storage?.removeItem('expiration')
  }
}

跟认证有关的逻辑我都放在
Auth
类中了

为了在 next.js 中可以愉快使用,还得做一些特别的处理,比如我增加了
storage
属性,读取的时候先判断
window
是否存在。

封装 axios

关于 API 的代码我都放在
src/services
目录下。

创建
src/services/api.ts
文件,代码比较长,分块介绍,可以看到所有配置相比之前 JavaScript 版本的都多了配置,对 IDE 自动补全非常友好。

先 import

import axios, {AxiosInstance, AxiosRequestConfig, AxiosResponse, CreateAxiosDefaults} from "axios";
import Global from '@/utilities/global'
import Auth from "@/utilities/auth";

Axios 配置

定义一下 axios 的配置

const config: CreateAxiosDefaults<any> = {
  method: 'get',
  // 基础url前缀
  baseURL: `${Global.baseUrl}/`,
  // 请求头信息
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  },
  // 参数
  data: {},
  // 设置超时时间
  timeout: 10000,
  // 携带凭证
  withCredentials: true,
  // 返回数据类型
  responseType: 'json'
}

统一接口返回值

设置统一的接口返回值,这个和我在 StarBlog 后端里封装的那套是一样的,现在基本是我写后端的标准返回值了,同时也发布了
CodeLab.Share
nuget包,可以快捷的引入这个统一的返回值组件。

// 统一接口返回值
export interface ApiResponse {
  data: any
  errorData: any
  message: string
  statusCode: number
  successful: boolean
}

定义
ApiClient

最后就是定义了
ApiClient
类,有点模仿 C# 的
HttpClient
内味了

这里面用到了 axios 的拦截器,发起请求的时候给 header 加上认证信息,返回的时候看看有没有错误,如果是 401 unauthorized 的话就跳转到登录页面。

export class ApiClient {
  private readonly api: AxiosInstance

  constructor() {
    this.api = axios.create({
      ...config,
    })

    this.api.interceptors.request.use(
      config => {
        config.headers.Authorization = `Bearer ${Auth.getToken()}`
        return config
      },
      error => {
        return error
      })

    this.api.interceptors.response.use(
      response => {
        return response
      },
      error => {
        let reason = error
        if (error && error.response) {
          if (error.response.data) {
            reason = error.response.data
            if (!reason.message) reason.message = error.message
          }
          if (error.response.status === 401) {
            location.href = '/login'
          }
        }

        return Promise.reject(reason)
      }
    )
  }

  public request(options: AxiosRequestConfig): Promise<ApiResponse> {
    return new Promise((resolve, reject) => {
      this.api(options).then((res: AxiosResponse<ApiResponse>) => {
        resolve(res.data)
        return false
      }).catch(error => {
        reject(error)
      })
    })
  }
}

export const api = new ApiClient()

export default api

代码比之前我在 StarBlog-Admin 里的简单一些,我要尽可能用较少的代码实现需要的功能。

编写具体接口调用

所有的接口调用我都写成 service (后端思维是这样的)

这里以发短信接口为例

创建
src/services/common.ts
文件,从刚才定义的
api.ts
里面引入
ApiClient
的对象,直接调用
request
方法就完事了。

参数类型是
AxiosRequestConfig
,不对 axios 本身做什么修改,我感觉比之前用 Antd Pro 魔改的接口舒服一些。

import {api} from './api'

export class SmsChannel {
  static local = 0
  static aliyun = 1
  static tencent = 2
}

export default abstract class CommonService {
  public static getSmsCode(phone: string, channel: number = SmsChannel.local) {
    return api.request({
      url: `api/common/getSmsCode`,
      params: {phone, channel}
    })
  }
}

小结

这样封装完比之前 StarBlog-Admin 的舒服很多,可惜之前那个项目用的是 vue2.x 似乎没法用 TypeScript。

就这样吧,大部分内容还是在 next.js 开发笔记中。

参考资料

scikit-learn
是一个用于Python的机器学习库,提供了大量用于数据挖掘和数据分析的工具。以下是对这些函数和方法的简要描述:

  1. clear_data_home
    : 清除数据集目录的内容。
  2. dump_svmlight_file
    : 将数据集保存为SVMLight格式的文件。
  3. fetch_20newsgroups
    : 下载20个新闻组的文本数据集。
  4. fetch_20newsgroups_vectorized
    : 下载并矢量化20个新闻组的文本数据集。
  5. fetch_lfw_pairs
    : 下载Labeled Faces in the Wild的成对图像。
  6. fetch_lfw_people
    : 下载Labeled Faces in the Wild的图像集。
  7. fetch_olivetti_faces
    : 下载Olivetti人脸数据集。
  8. fetch_species_distributions
    : 下载物种分布数据集。
  9. fetch_california_housing
    : 下载加州房价数据集。
  10. fetch_covtype
    : 下载Covtype数据集,这是一个用于分类土地覆盖类型的数据集。
  11. fetch_rcv1
    : 下载RCV1数据集,这是一个文本分类数据集。
  12. fetch_kddcup99
    : 下载KDD Cup '99数据集,这是一个用于网络入侵检测的数据集。
  13. fetch_openml
    : 从OpenML数据库中获取数据集。
  14. get_data_home
    : 获取或设置数据集的存储路径。
  15. load_diabetes
    ,
    load_digits
    ,
    load_files
    ,
    load_iris
    ,
    load_breast_cancer
    ,
    load_linnerud
    ,
    load_sample_image
    ,
    load_wine
    : 这些函数用于加载特定内置的数据集。
  16. make_biclusters
    ,
    make_circles
    ,
    make_classification
    ,
    make_checkerboard
    ,
    make_friedman1
    , ...
    make_swiss_roll
    :

    这些是用于生成模拟数据的函数,用于测试和验证算法。

常用的函数包括:

  • fetch_20newsgroups
    : 用于获取新闻组数据集,常用于文本分类任务。
  • fetch_california_housing
    : 用于获取加州房价数据集,常用于回归任务。
  • load_iris
    : 用于加载鸢尾花数据集,常用于分类任务。
  • make_classification
    : 用于生成模拟的二分类或多分类数据集,常用于测试分类算法。

这些函数和方法为机器学习提供了大量的数据集,使得用户可以快速地测试和验证其算法和模型。

内置的数据集

这些函数都是来自
sklearn.datasets
模块,用于加载不同的数据集。下面是每个函数的简要描述和常用的数据集:

  1. load_diabetes
    :这个函数用于加载
    糖尿病数据集
    ,通常用于回归分析。这个数据集包含从1991年到1994年的糖尿病患者的信息,如年龄、性别、体重、血压等。
  2. load_digits
    :这个函数用于加载
    手写数字数据集
    。它包含了1797个手写数字图片,每个图片的大小为8x8像素,每个像素的灰度值在0-15之间。这个数据集通常用于图像处理和机器学习中的分类任务。
  3. load_files
    :这个函数用于加载
    文件数据集
    ,通常用于文件存储和读取。
  4. load_iris
    :这个函数用于加载
    鸢尾花数据集
    。这个数据集包含了150个样本,每个样本有四个特征(花萼长度、花萼宽度、花瓣长度、花瓣宽度),用于分类三种鸢尾花。这个数据集是机器学习和数据挖掘领域中最著名的数据集之一。
  5. load_breast_cancer
    :这个函数用于加载
    乳腺癌数据集
    ,通常用于二分类问题(良性和恶性)。这个数据集包含了683个样本,每个样本有30个特征。
  6. load_linnerud
    :这个函数用于加载
    Linnerud数据集
    ,通常用于多变量回归分析。这个数据集包含了30个样本,每个样本有6个特征和3个目标变量。
  7. load_sample_image

    load_sample_images
    :这两个函数用于加载
    样本图像数据集
    ,通常用于图像处理和机器学习中的分类任务。
  8. load_svmlight_file

    load_svmlight_files
    :这两个函数用于加载
    SVMlight格式的数据集
    ,通常用于支持向量机分类和回归任务。
  9. load_wine
    :这个函数用于加载
    葡萄酒数据集
    ,通常用于分类任务。这个数据集包含了178个样本,每个样本有13个特征,用于分类三种葡萄酒类型。

常用的数据集包括
load_iris
,
load_digits
,
load_wine
,
load_breast_cancer
等。这些数据集在机器学习和数据分析领域中非常常见,可用于演示算法、训练模型和测试模型性能等。

from sklearn import datasets
iris = datasets.load_iris()

模拟数据集

这些函数都是来自
sklearn.datasets
模块,用于生成模拟数据集。下面是对每个函数的简要解释,以及哪些是常用的:

  1. make_biclusters
    :生成一个二聚类数据集。不常用。
  2. make_blobs
    :生成一个简单的
    二维聚类数据集
    。常用,主要用于演示聚类算法。
  3. make_circles
    :生成一个表示圆形的二分类数据集。不常用。
  4. make_classification
    :生成模拟的
    二分类或多分类数据集
    。常用,主要用于分类算法的演示。
  5. make_checkerboard
    :生成一个棋盘图案的数据集。不常用。
  6. make_friedman1
    ,
    make_friedman2
    ,
    make_friedman3
    :生成弗里德曼数据集,主要用于回归分析。不常用。
  7. make_gaussian_quantiles
    :生成高斯分布但具有不同分位数的高斯分布数据。不常用。
  8. make_hastie_10_2
    :生成一个10x2的二分类数据集,主要用于决策树的训练。不常用。
  9. make_low_rank_matrix
    :生成一个低秩矩阵,通常用于矩阵分解或低秩表示的算法。不常用。
  10. make_moons
    :生成半月形状的二分类数据集。不常用。
  11. make_multilabel_classification
    :生成多标签分类的数据集。不常用。
  12. make_regression
    :生成用于
    线性回归的仿真数据集
    。常用,主要用于回归分析的演示。
  13. make_s_curve
    :生成一个S形的二分类数据集。不常用。
  14. make_sparse_coded_signal
    :生成稀疏编码信号的数据集。不常用。
  15. make_sparse_spd_matrix
    :生成稀疏对称正定矩阵。不常用。
  16. make_sparse_uncorrelated
    :生成稀疏且不相关的数据集。不常用。
  17. make_spd_matrix
    :生成对称正定矩阵。不常用。
  18. make_swiss_roll
    :生成瑞士卷形状的数据集,通常用于流形学习算法的演示。不常用。

常用的有
make_blobs
,
make_classification
, 和
make_regression
,因为这些数据集经常用于基础机器学习算法的演示和验证。

make_blobs 二维聚类数据集

sklearn.datasets.make_blobs(
        n_samples=100, # 样本数量
        n_features=2, # 特征数量
        centers=None, # 中心个数 int,就是有几堆数据
        cluster_std=1.0, # 聚簇的标准差
        center_box(-10.0, 10.0), # 聚簇中心的边界框
        shuffle=True, # 是否洗牌样本
        random_state=None #随机种子
        )
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
import numpy as np

X, y = make_blobs(n_samples=500,
                  n_features=2,
                  centers=3,
                  cluster_std=1.5,
                  random_state=1) 
plt.figure()
plt.title('make_blobs')
plt.scatter(X[:, 0], X[:, 1], marker='o', c=np.squeeze(y), s=30)
plt.show()

image

make_classification 二分类或多分类数据集

sklearn.datasets.make_classification(
        n_samples=100, # 样本个数
        n_features=20, # 数据的特征量数,数据是一列还是几列
        n_informative=2, # 有效特征个数
        n_redundant=2, # 冗余特征个数(有效特征的随机组合)
        n_repeated=0, # 重复特征个数(有效特征和冗余特征的随机组合)
        n_classes=2, # 分类数量,默认为2
        n_clusters_per_class=2, # 蔟的个数
        weights=None, # 每个类的权重 用于分配样本点
        flip_y=0.01, # 随机交换样本的一段 y噪声值的比重
        class_sep=1.0, # 类与类之间区分清楚的程度
        hypercube=True, # 如果为True,则将簇放置在超立方体的顶点上;如果为False,则将簇放置在随机多面体的顶点上。
        shift=0.0, # 将各个特征的值移动,即加上或减去某个值
        scale=1.0, # 将各个特征的值乘上某个数,放大或缩小
        shuffle=True, # 是否洗牌样本
        random_state=None) # 随机种子
from sklearn.datasets import make_classification
import matplotlib.pyplot as plt
import matplotlib

X, y = make_classification(n_samples=50, n_features=2, n_redundant=0, random_state=0)
plt.scatter(X[:, 0], X[:, 1], c=y, cmap=matplotlib.cm.get_cmap(name="bwr"), alpha=0.7)
plt.grid(True)
plt.show()

image

make_regression 线性回归的仿真数据集

sklearn.datasets.make_regression(
    n_samples=100, #样本数
    n_features=100, #特征数(自变量个数)
    n_informative=10, #参与建模特征数
    n_targets=1, #因变量个数
    bias=0.0, #偏差(截距)
    effective_rank=None, 
    tail_strength=0.5, 
    noise=0.0, #噪音
    shuffle=True, 
    coef=False, #是否输出coef标识
    random_state=None # 随机种子
)
import matplotlib.pyplot as plt
import matplotlib
from sklearn.datasets import make_regression

X, y = make_regression(n_samples=10, n_features=1, n_targets=1, noise=1.5, random_state=1)

plt.scatter(X, y, c=y, s=50, cmap=matplotlib.cm.get_cmap(name='viridis'), alpha=0.7)
plt.show()

image

注:随机种子
种子的取值范围通常是一个整数,其具体取值会根据不同的随机数生成方法而有所不同。在Python的
numpy
库中,
RandomState
对象的种子参数通常是一个非负整数。这个种子值用于初始化随机数生成器的状态,从而确定将要生成的随机数序列。

一般来说,种子的取值范围可以是从0到任何正数的整数,但具体取值范围可能受到实现细节或特定算法的限制。如果种子值太大或太小,可能会导致生成随机数序列的质量下降或无法生成随机数。因此,在实际应用中,需要根据具体需求和算法的要求来选择合适的种子值。

确定随机数种子的大小并没有固定的规则,因为这取决于具体的应用场景和需求。以下是一些可能影响种子大小选择的因素:

  1. 随机性需求
    :如果需要更强的随机性,可以选择较大的种子值。较大的种子值可以增加随机数生成器的初始状态,从而产生更随机的随机数序列。
  2. 可重复性需求
    :如果需要实验或模拟的可重复性,可以选择较小的种子值。较小的种子值可以使随机数生成器具有较小的初始状态,从而产生更可预测的随机数序列。
  3. 算法和实现细节
    :不同的随机数生成算法和实现细节可能会有不同的种子取值范围和要求。因此,在选择种子值时,需要了解所使用的算法和实现细节的要求。
  4. 测试和调试
    :在开发和测试阶段,可能需要对不同的种子值进行测试和调试,以确定最佳的种子值。

总的来说,确定种子值的大小需要根据具体的需求和应用场景进行权衡和选择。如果需要更强的随机性,可以选择较大的种子值;如果需要实验或模拟的可重复性,可以选择较小的种子值。同时,需要考虑算法和实现细节的要求,并进行适当的测试和调试。

https://scikit-learn.org/stable/modules/classes.html#module-sklearn.datasets
源码:
https://gitee.com/VipSoft/VipPython/tree/master/scikit_learn

值对象
虽然经常被掩盖在实体的阴影之下,但它却是非常重要的DDD部件。值对象的常见例子包括数字,比如3、10和29.51;或者文本字符串,比如“hello world”;或者日期、时间;还有更加详细的对象,比如某人的全名,其中包含姓氏、名字和头衔;再比如货币、颜色、电话号码和邮寄地址等。当然还有更加复杂的值对象。

值对象的优点:值对象用于度量和描述事物,我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。

我们应该尽量使用值对象来建模而不是实体对象,你可能对此非常惊讶。即便一个领域概念必须建模成实体,在设计时也应该更偏向于将其作为值对象容器,而不是子实体容器。这并不是源自于无端的偏好,而是因为我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。

在设计得当的情况下,我们可以对值对象实例进行创建和传递,甚至在使用完之后将其直接扔掉。我们不用担心客户端对值对象的修改。一个值对象的生命周期可长可短,它就像一个无害的过客在系统中来来往往。

那么,我们如何确定一个领域概念应该建模成一个值对象呢?此时我们需要密切关注值对象的特征。

当你只关心某个对象的属性时,该对象便可作为一个值对象。为其添加有意义的属性,并赋予它相应的行为。我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。

值对象的特征

首先,在将领域对象概念建模成值对象时,我们应该将通用语言考虑在内,这是建模值对象的首要原则,该原则属于整个DDD设计。

当你决定一个领域概念是否是一个值对象时,你需要考虑它是否拥有以下特征:

  • 它度量或者描述了领域中的一件东西。
  • 它可以作为不变量。
  • 它将不同的相关属性组合成一个整体概念。
  • 当度量和描述改变时,可以用另一个值对象予以替换。
  • 它可以和其他值对象进行相等性比较。
  • 它不会对协作对象造成副作用。

对于以上特征,将在下面做详细讲解。在使用这些方法分析模型时,你会发现很多领域概念都可以设计成值对象,而不是先前认为的实体对象。

度量或描述

当你的模型中的确存在一个值对象时,不管你是否意识到,它都不应该成为你领域中的一样东西,而只是用于度量或描述领域中某件东西的一个概念。一个人拥有年龄,这里的年龄并不是一个实在的东西,而只是作为你出生了多少年的一种度量。一个人拥有名字,同样这里的名字也不是一个实在的东西,而是描述了如何称呼这个人。

该特征和下面的“概念整体”特征是紧密联系在一起的。

不变性

一个值对象在创建之后便不能改变。在使用C#编程时,我们使用构造函数来创建值对象实例,此时传入的参数包含了该值对象的所有状态所需的数据信息。所传入的参数既可以作为该值对象的直接属性,也可以用于计算出新的属性。

光凭初始化是不能保证值对象的不变性。在值对象初始化之后,任何方法都不能对该对象的属性状态进行修改。在上面的例子中,只有setRatings 和 initialize 方法可以修改对象的状态,而它们只在对象构建过程中才被使用。方法 setRatings 被声明为 private,外界不能直接调用。

此外,BusinessPriority 必须保证除了构造函数之外,其他方法均不能调用 setter 方法。

根据需要,有时我们可以在值对象中维持对实体对象的引用。在这种情况下我们需要谨慎行事。当实体对象的状态发生改变时,引用它的值对象也将发生改变,由此违背了值对象的不变性。因此,在值对象中引用实体时,我们的出发点应该是不变性、表达性和方便性。否则,如果实体对象有可能违背值对象的不变性,那么我们便没有理由在值对象中引用实体对象。在后面会讲到值对象的无副作用特征。

如果你认为一个值对象必须通过行为方法进行改变,那么你得问问自己这是否有必要。在这种情况下可以用其他值对象来替换吗?使用值对象替换可以简化设计。

有时将一个对象设计成不变对象是没有意义的,此时往往意味着该对象应该建模成一个实体对象,见
实体

概念整体

一个值对象可以只处理单个属性,也可以处理一组相关联的属性。在这组相关联的属性中,每一个属性都是整体属性所不可或缺的组成部分,这和简单地将一组属性组装在对象中是不同的。如果一组属性联合起来并不能表达一个整体上的概念,那么这种联合并无多大用处。

在Ward Cunningham的
整体值对象
模式中提到,值对象{50,000,000美元}具有两个属性,一个是50,000,000 ,一个是美元。单独一个 50,000,000 可能表示另外的意思,而单独一个“美元”更不能表示该值对象。只有当这两者联合起来才是一个表达货币度量的概念整体。因此我们并不希望将来表示 50,000,000 的 Amount 和表示美元的 Currency 看作两个相对独立的属性,比如:

//不正确建模的ThingOfWorth
    public classThingOfWorth
{
publicThingOfWorth()
{
}
private stringName;private decimalAmount;private stringCurrency;
}

在上面的例子中,ThingOfWorth 的客户端必须知道什么时候应该同时使用 Amount 和 Currency ,并且还应该知道如何使用这两个属性,原因在于这两个属性并没有组成一个概念整体。

要正确地表达货币度量,我们不应该将以上两个属性分离开来,而应该将它们建模成一个整体值对象:{50,000,000美元}。

public classMonetaryValue
{
public decimal Amount { get; private set; }public string Currency { get; private set; }public MonetaryValue(decimal anAmount,stringaCurrency)
{
SetAmount(anAmount);
SetCurrency(aCurrency);
}
}

这并不是说 MonetaryValue 就是完美的,我们还可以用Currency值对象类型来表示货币单位。这里可以将 Currency 属性从 string 类型替换成 Currency 类型。同时,我们还可以使用 Factory 和 Builder 来创建该值对象。

在一个领域中,概念的整体性是非常重要的,因此作为整体值对象的 MonetaryValue 已经不再单单是一个起描述作用的描述属性,而是一个资产属性。一个值对象可以拥有一个或多个描述属性,但是对于持有该值对象实例的对象来说,该值对象便是一个资产属性。

以下是改进后的代码:

//正确建模的ThingOfWorth
    public classThingOfWorth
{
publicThingOfWorth()
{
}
private ThingName Name; //资产属性 public MonetaryValue Worth { get; private set; }//资产属性 }

上面的代码还存在一点变化,ThingOfWorth 中的 Name 和 Worth 同样重要,因此我们用 ThingName 类型取代了原来的 string 类型。虽然用 string 类型在一开始看来已经足够了,但是在随后的迭代中,它将带来问题。围绕着 Name 展开的领域逻辑有可能从 ThingOfWorth 模型中泄漏出去。如下代码所示:

//有客户端处理命名相关逻辑
String name =thingOfWorth.name();
String capitalizedName
name.substring(
0,1).toUpperCase()+ name.substring(1).toLowerCase();

在上面的代码中,客户端自己试图解决 Name 的大小写问题。通过定义 ThingName 类型,我们可以将与 Name 有关的所有逻辑操作集中在一起。以上面的例子来说, ThingName 可以在初始化时对 Name 进行格式化,而不是客户端自身来处理。

值对象的构造函数用于保证概念整体的有效性和不变性。

如果你试图将多个属性加在一个实体上,但这却弱化了各个属性之间的关系,那么此时你便应该考虑将这些相互关联的属性组合在一个值对象中。每个值对象都是一个内聚的概念整体,它表达了通用语言中的一个概念。

可替换性

在你的模型中,如果一个实体所引用的值对象能够正确地表达其当前的状态,那么这种引用关系可以一直维持下去。否则,我们需要将整个值对象替换成一个新的值对象实例。

值对象的可替换性可以通过数字的替换性来理解。假设领域中有一个名为 total 的概念,该概念用整数表示。如果total的当前值被设成了3,但是之后需要重设为4,此时我们并不会将整数3修改成4,而是简单地将total的值重新赋值为4.

int total = 3;//稍后...
total = 4;

这种替换值的方法是非常显然的,但是它却向我们展示了很重要的一点。在上例中,我们只是将total的值从3替换成了4。这并不是过度简化,而正是值对象替换工作方式。考虑下面一种更复杂的值对象替换:

FullName name = 
new FullName ("Vaughn", "Vernon");//稍后...
name = 
new FullName ("Vaughn", "L", "Vernon");

首先,name通过firstName和lastName进行初始化,随后name变量被替换成了另一个FullName值对象实例,该实例中包含了 firstName、middleName和lastName。这里,我们并没有使用FullName的某个方法来改变其自身的状态,因为这样破坏了值对象的不变性。我们使用了简单的替换将另一个FullName实例的引用重新赋值给了name变量。这种方式的表达性并不强,我们将在下文讲到更好的替换方法。

值对象相等性

在比较两个值对象实例时,我们需要检查这两个值对象的相等性。在整个系统中,有可能存在很多相等的值对象实例,但它们并不表示相同的实例引用。相等性通过比较两个对象的类型和属性来决定。如果两个对象的类型和属性都相等,那么这两个对象也是相等的。进而。=,如果两个或多个值对象实例是相等的,我们便可以用其中一个实例来替换另一个实例。

以下代码测试两个FullName值对象的相等性:

publicboolean equals (Object an0bject) {
boolean equalObjects
= false;if (anObject != null && this.getClass() =anObject.getClass()) {
FullName typedobject
=(FullName) anObject;
equalobjects
= this.firstName ().equals (typedObject.firstName()) & ) && this.lastName ().equals (typedObject.lastName ())returnequalobjects;
}

思考一下,值对象的哪些特征可以用来支持聚合的唯一标识性。我们需要值对象的相等性,比如在通过实体查询聚合时便会用到。同时,不变性也是重要的。实体的唯一标识是不能改变的,这可以部分地通过值对象的不变性达到。此外,我们还可以从值对象的概念整体特性中得到好处,因为实体的唯一标识是根据通用语言来命名的,并且需要在一个实例中包含所有的可以表示唯一标识的属性。然而,这里我们并不需要值对象的可替换性,因为我们不会替换聚合根的唯一标识。

无副作用行为

一个对象的方法可以设计成一个
无副作用函数
。这里的函数表示对某个对象的操作,它只用于产生输出,而不会修改对象的状态。由于函数执行的过程中没有状态改变,这样的函数操作也称为无副作用函数。

对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。你可以将这种特性看作是不变性的一部分,但是我更倾向于将该特性从不变性中分离出来,因为这样做可以强调出值对象的一大好处。否则,我们可能只会将值对象看成一个属性容器,而忽略了值对象模式的一个功能强大的特性——无副作用函数。

在下面的例子中,通过在一个FullName对象上调用无副作用方法将该对象本身替换成另一个实例:

FullName name = new FullName("Vaughn", "Vernon");//稍后...
name = name.withMiddleInitial("L");

这和先前“可替换性”一节中的例子所产生的结果是一样的,但是代码更具表达性。这个无副作用的 withMiddleInitial() 方法的实现如下:

publicFullName withMiddleInitial(String aMiddleNameOrInitial){if(aMiddleNameOrInitial == null) {throw newIllegalArgumentException("Must provide a middle name or initial.");

String middle
=aMiddleNameOrInitial.trim ();if(middle.isEmpty()){throw newIllegalArgumentException("Must provide a middle name or initial.");
}
return newFullName(this.firstName()
middle.substring(
0,1).toUpperCase(),this.lastName());
}

在上例中,withMiddleInitial() 方法并没有修改值对象的状态,因此它不会产生副作用。该方法通过已有的 firstName 和 lastName ,外加传入的 middlerName 创建了一个新的FullName值对象实例。此外,withMiddleInitial() 方法还捕获到了重要的领域业务逻辑,从而避免了将这些逻辑泄漏到客户端。

当值对象引用实体对象

一个值对象允许对传入的实体对象进行修改吗?如果值对象中的确有方法会修改实体对象,那么该方法还是无副作用的吗?该方法容易测试吗?既容易,也不容易。因此,如果一个值对象方法将一个实体对象作为参数时,最好的方式是,让实体对象使用该方法返回的结果来修改其自身的状态。

然而,这种方式存在一个问题。例如,我们有个实体对象Product,该对象被值对象BusinessPriority所使用:

float priority = businessPriority.priorityOf(product)

这里存在以下问题:

    • 这里的BusinessPriority值对象不仅依赖于Product,还试图去理解该实体的内部状态。我们应该尽量地使用值对象只依赖于它自身的属性,并且只理解它自身的状态。虽然在有些情况下这并不可行,但这是我们的目标。
    • 阅读本段代码的人并不知道使用了Product的哪些部分。这种表达方法并不明确,从而降低了模型的清晰性。更好的方式是只传入需要用到的Product属性。
    • 更重要的是,在将实体作为参数的值对象方法中,我们很难看出该方法是否会对实体进行修改,测试也将变得非常困难。因此,即便一个值对象承诺不会修改实体,我们也很难证明这一点。

有了以上的分析,我们需要对以上的值对象进行改进。要增加一个值对象的健壮性,我们传给值对象方法的参数依然应该是值对象。这样我们可以获得更高层次的无副作用行为。要实现这样的目标并不困难:

float priority =businessPriority.priority(
product.businessPriorityTotals());

在上例中,我们只需要将Product实体的BusinessPriorityTotals值对象传给priority()方法即可。你可能会认为priority()方法应该返回一个值对象类型,而不是float类型。这是正确的,特别是当priority是通用语言中的正式概念的时候。这种决定来自持续改进模型的结果。

如果你打算使用语言特性提供的基本值对象,而不是使用特定的值对象,那么你便是在欺骗自己的模型了。我们是无法将领域特定的无副作用函数分配给语言提供的基本值对象的。任何领域特有行为都将从值中分离出来。即便编程语言允许我们向基本值对象中添加新的行为,这能够在深层次上捕获领域概念吗?

最小集成化

在所有的DDD项目中,通常存在多个限界上下文,这意味着我们需要找到合适的方法对这些上下文进行继承。当模型概念从上游上下文流入下游上下文时,尽量使用值对象来表示这些概念。这样的好处是可以达到最小化继承,即可以最小化下游模型中用于管理职责的属性数目。使用不变的值对象使得我们做更少的职责假设。

重用限界上下文中的一个例子:上游的“身份与访问上下文”会影响下游的“协作上下文”,如下图所示。在“身份与访问上下文”中,两个聚合分别为User和Role。在“协作上下文”中,我们关心的是一个User是否拥有一个特定的Role,比如Moderator。“协作上下文”使用它的
防腐层
向“身份与访问上下文”的
开放主机服务
提出查询。如果这个集成的查询过程表明某个User拥有Moderator角色,协作上下文便会创建一个代表性的Moderator对象。

Moderator和其他Collaborator的子类如下图,这些对象被建模成了值对象。这些值对象的实例通过静态方式创建。这里的重点在于,上游的身份与访问上下文对下游的协作上下文的影响被最小化了。虽然上游上下文需要处理许多属性,但是它传给下游的Moderator却只包含了通用语言中的关键性属性。此外,Moderator并不包含Role聚合中属性,而是通过自身的名字表明一个用户所扮演的角色。我们选择静态创建Moderator的方式,并且没有必要使下游中的值对象与上游保持同步。这种考虑了服务质量(Quantity of Service)的契约可以大大地减轻下游上下文的负担。

当然, 有时下游上下文的对象必须和远程上下文的聚合保持最终一致性。在这种情况下,我们可以在下游上下文中设计一个聚合,因为该聚合实体可以用于维护状态变化。但是,我们应该尽量地避免这种建模方式,在有可能的情况下使用值对象来完成限界上下文之间的集成,这对于许多需要消费标准类型的上下文来说都是适用的。

用值对象表示标准类型

在许多应用程序和系统中,都会使用到
标准类型
。标准类型是用于表示事物类型的描述性对象。系统中既有表示事物的实体和描述实体的值对象,同时还存在标准类型来区分不同的类型。

假设你的通用语言定义了一个PhoneNumber值对象,同时需要为每个PhoneNumber对象制定一个类型。“这个号码是家庭电话、移动电话、工作电话还是其他类型的电话号码?”不同类型的电话号码类型需要建模成一种类的层级关系吗?为每一个类型创建一个类对于客户端使用来说是非常困难的。此时,你需要使用标准类型来描述不同类型的电话还吗,比如Home、Work或者Other。我们需要一个PhoneNumberType值对象来表示一个PhoneNumber值对象的号码类型。使用标准类型可以避免伪造号码类型。

根据标准化程度,这些类型可能只能用在应用程序级别,也或者可以在不同的系统间共享,更或者可以称为一种国际标准。

标准化程度有时会影响到对标准类型的获取,同时还有可能影响到标准类型在模型中的使用方式。

我们可以将这些概念建模成实体,因为它们在自己的限界上下文中都拥有自己的生命周期。在不考虑创建方式和由什么样的标准组织维护的情况下,在作为消费方的我限界上下文中,我们应该尽可能地将这些概念建模成值对象。这是一种很好的做法,因为这些概念本来就是用来度量和描述事物的,而值对象便是建模度量和描述概念的最佳方式。

为了维护方便,最好是为标准类型创建单独的限界上下文。在这样的上下文中,这些标准类型便是实体了,拥有了持久化的生命周期,并且还含有属性,比如identity、name和description。可能还有其他属性,但是这里的3个属性对于消费上下文来说是常见的。通常来说,我们只会使用其中一个属性,这也是最小化集成的目标。

枚举也是实现标准类型的一种简单方法。枚举提供了一组有限数量的值对象,它是非常轻量的,并且无副作用。但是该值对象的文本描述在什么地方呢?对于此,存在两种答案。通常来说,没有必要为标准类型提供描述信息,只需要名字就足够了。为什么?文本描述通常只会在
用户界面层
中才能用到,此时可以用一个显示资源和类型名字匹配起来。很多时候用于显示的文本都需要进行本地化(比如在多语言环境中),因此将这种功能放在模型中并不合适。通常来说,在模型中使用标准类型的名字便是最好的方式。另一种答案是,在枚举中已经存在描述信息了,可以调用toString()方法来获得标准类型的文本描述。

一个共享的不变值对象可以从持久化存储中获取,此时可以通过标准类型的
领域服务

工厂
来获取值对象。我们应该为每组标准类型创建一个领域服务或工厂,如图所示。服务或工厂将按需从持久化存储中获取标准类型,而客户方并不知道这些标准类型是来自数据库的。另外,使用领域服务或工厂还使得我们可以加入不同的缓存机制,由于值对象在数据库中是只读的,并且在整个系统中是不变的,缓存机制也将变得更加简单和安全。

在这种情况下,领域服务或工厂将为每一种标准类型提供静态创建的不变值对象实例。由于是静态的,数据库中标准类型的改变不会自动反映到代码中。如果你希望这两者是同步的,那么你应该创建一些定制化的方案来查询并更新模型的状态。

测试值对象

为了强调测试驱动,在实现值对象之前,让我们先来看看测试。通过模拟客户端对值对象的使用,这些测试可以驱动出对领域模型的设计。

这里,我们所关心的并不仅仅是单元测试的各个方面,而是演示客户端是如何使用我们的领域模型的。在设计领域模型时,从客户端的角度思考有助于捕获关键的领域概念。否则,我们便是在从自己的角度设计模型,而不是业务的角度。

我们可以这么看待这种测试风格:如果我们要为自己的模型编写一个用户手册,我们便可以通过这些测试代码来展示客户端对领域模型的使用。

当然,也不是说我们就不应该编写单元测试,对于团队所要求的所有类型的测试,我们都应该完成。但是,每种测试的关注点是不同的。单元测试和行为测试有它们自己的关注点,而下面的模型则有另外的关注点。

我们创建了一个BusinessPriority类,该类用于衡量每一个待定项的业务价值,它返回的是成本百分比,或者当前待定项与其他特定项的比较成本。同时BusinessPriority还向外提供了开发某个待定项的总价值,或者开发当前待定项与其他待定项的比较价值。此外,该类还提供了当前待定项与其他待定项相比起来的业务优先级。

packagecom.saasovation.agilepm.domain.model.piroduct;importcom.saasovation.agilepm.domain.model.DonmainTest;importjava.text.NumberFormat;public classBusinessPriorityTest extendsDomainTest {publicBusinessPriorityTest(){super();
}
//帮助方法 privateNumberFormat oneDecimal()
{
return this.decimal(1);
}
privateNumberFormat twoDecimals(){return this.decimal(2);
}
private NumberFormat decimal(intaNumberOfDecimals){
NumberFormat fmt
=NumberFormat.getInstance();
fmt.setMinimumFractionDigits (aNumberOfDeciImals)
fmt.setMaximumFractionDigits (aNumberOfDecimals),
returnfmt;
}
public void testCostPercentageCalculation() throwsException {
BusinessPriority businessPriority
= newBusinessPriority(new BusinessPriorityRatings (2, 4,1,1));

BusinessPriority businessPriorityCopy
= newBusinessPriority (businessPriority);

assertEquals (businessPriority, businessPriorityCopy);

BusinessPriorityTotals totals
= new BusinessPriorityTotals(53, 49, 53 + 49,37,33);float cost =businessPriority.costPercentage (totals);

assertEquals (
this.oneDecimal().format(cost),"2.7");
assertEquals (businessPriority, businessPriorityCopy);
}
}

在测试值对象的不变性时,每个测试首先一个
BusinessPriority
实例,然后通过复制构造函数创建一个与之相等的复制实例。测试的第一个断言保证了复制构造函数所创建的实例和原来的实例是相等的。接下来经过一系列计算和赋值之后,最后再执行一次相等断言。

之后,需要测试优先级、总价值和价值百分比,他们可以使用和以上测试相同的测试模板:

在创建测试时,我们应该保证领域专家能够读懂这些测试,即测试应该具有领域含义。

实现

通常来说,至少都会为值对象创建两个构造函数。

public final class BusinessPriority implementsSerializable{private static final long serialVersionUID = 11L;privateBusinessPriorityRatings ratings;publicBusinessPriority (BusinessPriorityRattings aRatings){super();this.setRatings(aRatings);
}
publicBusinessPriority(BusinessPriority aBusinessPriority){this(aBusinessPriority.ratings());

}

第一个构造函数接受用于构建对象状态的所有属性参数,它是主要的构造函数。该构造函数首先初始化默认的对象状态,对于基本属性的初始化通过调用私有的setter方法实现。该私有的setter方法向我们展示了一种自委派性。只有主构造函数才能使用自委派性来设置属性值,除此之外,其他方法都不能使用setter方法。由于值对象中的所有setter方法都是私有,消费方是没有机会直接调用这些setter方法的。这是保持值对象不变性的两个主要因素。

第二个构造函数用于将一个值对象复制到另一个新的值对象,即复制构造函数。该构造函数采用浅复制(Shallow Copy)的方式,因为它也是将构造过程委派给主构造函数的,先从原对象中取出各个属性值,再将这些属性值作为参数传给主构造函数。当然,我们也可以采用深复制(Deep Copy)或者克隆的方式,即为每个所引用的属性都创建一份自身的备份。然而,这种方式既复杂,也没有必要。当需要深度复制时,我们才考虑添加该功能。但是对于不变的值对象来说,在不同的实例之间共享属性是不会出现什么问题的。

现在,我们来实现值对象的策略部分:

这些无副作用方法的名字是重要的。虽然所有的方法都返回值对象(因为它们都是CQS查询方法),但没有为方法加上“get”前缀。这种方法使得代码与通用语言保持一致。使用getValuePercentage()只是技术上的用法,但是valuePercentage()则是一种流畅的,可读的语言表达。

下面一组方法包含了标准的equals(),hashCode()和toString()方法:

这里的 equals()方法用于检查不同值对象的相等性。通常来说,在比较相等性时,我们将省略对非null的检查。传入的参数对象必须与当前对象具有相同的类型。在类型相同时,equals()方法会对两个对象所有属性进行比较,当它们之间每组对应的属性都相等时,两个整体值对象则相等。

根据Java标准,equals()方法和hashCode()方法拥有相同的契约,即如果两个对象是相等的,那么它们的hashCode()方法也应该返回相同的结果。

BusinessPriority还剩下几个方法:

无参数构造函数是为一些框架准备的,比如Hibernate。由于该构造函数总是隐藏起来的,我们没有必要担心客户端会使用该构造函数来创建非法对象实例。在构造函数和setter/getter被隐藏的情况下,Hibernate依然可以以工作。这个无参数的构造函数使得Hibernate或其他工具能够对对象进行重建,比如重建保存在持久化存储中的对象实例。