first commit

This commit is contained in:
张乾 2024-10-16 11:19:41 +08:00
parent e0146c37db
commit b2fae18d7e
71 changed files with 12539 additions and 0 deletions

View File

@ -0,0 +1,501 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 语言不仅是工具,还是思维方式
你好,我是 Pedro一名普普通通打工人平平凡凡小码农。
可能你在课程留言区看到过我,也跟我讨论过问题。今天借着这篇用户故事的机会,正好能跟你再多聊几句。
我简单整理了一下自己入坑编程以来的一些思考,主要会从思维、语言和工具三个方面来聊一聊,最后也给你分享一点自己对 Rust 的看法,当然以下观点都是“主观”的,观点本身不重要,重要的是得到观点的过程。
从思维谈起
从接触编程开始,我们就已经开始与编程语言打交道,很多人学习编程的道路往往就是熟悉编程语言的过程。
在这个过程中,很多人会不适应,写出的代码往往都不能运行,更别提设计与抽象。出现这个现象最根本的原因是,代码体现的是计算机思维,而人脑思维和计算机思维差异巨大,很多人一开始无法接受两种思维差异带来的巨大冲击。
那么,究竟什么是计算机思维?
计算机思维是全方位的,体现在方方面面,我以个人视角来简单概括一下:
自顶向下:自顶向下是计算机思维的精髓,人脑更加适合自底向上。计算机通过自顶向下思维将大而难的问题拆解为小问题,再将小问题逐一解决,从而最终解决大问题。
多维度、多任务:人脑是线性的,看问题往往是单维的,我们很难同时处理和思考多个问题,但是计算机不一样,它可以有多个 CPU 核心,在保存上下文的基础上能够并发运行成百上千的任务。
全局性:人的精力、脑容量是有限的,而计算机的容量几乎是无限的;人在思考问题时,限于自己的局部性,拿到局部解就开始做了,而计算机可以在海量数据的基础上再做决策,从而逼近全局最优。
协作性:计算机本身就是一件极其精细化的工程艺术品,它复杂精巧,每个部分都只会做自己最擅长的事情,比如将计算和存储剥离,计算机高效运作的背后是每个部分协作的结果,而人更擅长单体作战,只有通过大量的训练,才能发挥群体的作用。
迭代快:人类进化、成长是缓慢的,直到现在,很多人的思维方式仍旧停留在上个世纪,而计算机则不同,进入信息时代后,计算机就遵循着摩尔定律,每 18 个月翻一番,十年前的手机放在今天可能连微信都无法正常运行。
取舍:在长期的社会发展中,人过分喜欢强调对与错,喜欢追求绝对的公平,讽刺的是,由二进制组成的计算机却不会做出非黑即白的决策,无论是计算机本身(硬件),还是里面运行的软件,每一个部分都是性能、成本、易用性多角度权衡的结果。
So on…
当这些思维直接体现在代码里面,比如,自顶向下体现在编程语言中就是递归、分治;多维度、多任务的体现就是分支、跳转、上下文;迭代、协作和取舍在编程中也处处可见。
而这些恰恰是人脑思维不擅长的点,所以很多人无法短时间内做到编程入门。想要熟练掌握编程,就必须认识到人脑与计算机思维的差异,强化计算机思维的训练,这个训练的过程是不太可能短暂的,因此编程入门必须要消耗大量的时间和精力。
语言
不过思维的训练和评估是需要有载体的,就好比评估你的英文水平,会考察你用英文听/说/读/写的表达能力。那我们的计算机思维怎么表达呢?
于人而言,我们可以通过肢体动作、神情、声音、文字等来表达思维。在漫长的人类史中,动作、神情、声音这几种载体很难传承和传播,直到近代,音、视频的兴起才开始慢慢解决这个问题。
文字,尤其是语言诞生后的文字,成了人类文明延续、发展的主要途径之一,直至今天,我们仍然可以通过文字来与先贤对话。当然,对话的前提是,这些文字你得看得懂。
而看得懂的前提是,我们使用了同一种或类似的语言。
回到计算机上来,现代计算机也是有通用语言的,也就是我们常说的二进制机器语言,专业一点叫指令集。二进制是计算机的灵魂,但是人类却很难理解、记忆和应用,因此为了辅助人类操纵计算机工作,上一代程序员们对机器语言做了第一次抽象,发明了汇编语言。
但伴随着硬件、软件的快速发展程序代码越来越长应用变得愈来愈庞大汇编级别的抽象已经无法满足工程师对快速高效工作的需求了。历史的发展总是如此地相似当发现语言抽象已经无法满足工作时工程师们就会在原有层的基础上再抽象出一层而这一层的著名佼佼者——C语言直接奠定了今天计算机系统的基石。
从此以后,不计其数的编程语言走向计算机的舞台,它们如同满天繁星,吸引了无数的编程爱好者,比如说迈向中年的 Java 和新生代的 Julia。虽然学习计算机最正确的途径不是从语言开始但学习编程最好、最容易获取成就感的路径确实是应该从语言入手。因此编程语言的重要性不言而喻它是我们走向编程世界的大门。
C 语言是一种命令式编程语言,命令式是一种编程范式;使用 C 写代码时,我们更多是在思考如何描述程序的运行,通过编程语言来告诉计算机如何执行。
举个例子,使用 C 语言来筛选出一个数组中大于 100 的数字。对应代码如下:
int main() {
int arr[5] = { 100, 105, 110, 99, 0 };
for (int i = 0; i < 5; ++i) {
if (arr[i] > 100) {
// do something
}
}
return 0;
}
在这个例子中,代码撰写者需要使用数组、循环、分支判断等逻辑来告诉计算机如何去筛选数字,写代码的过程往往就是计算机的执行过程。
而对于另一种语言而言,比如 JavaScript筛选出大于 100 的数字的代码大概是这样的:
let arr = [ 100, 105, 110, 99, 0 ]
let result = arr.filter(n => n > 100)
相较于 C 来说JavaScript 做出了更加高级的抽象,代码撰写者无需关心数组容量、数组遍历,只需将数字丢进容器里面,并在合适的地方加上筛选函数即可,这种编程方式被称为声明式编程。
可以看到的是,相较于命令式编程,声明式编程更倾向于表达在解决问题时应该做什么,而不是具体怎么做。这种更高级的抽象不仅能够给开发者带来更加良好的体验,也能让更多非专业人士进入编程这个领域。
不过命令式编程和声明式编程其实并没有优劣之分,主要区别体现在两者的语言特性相较于计算机指令集的抽象程度。
其中,命令式编程语言的抽象程度更低,这意味着该类语言的语法结构可以直接由相应的机器指令来实现,适合对性能极度敏感的场景。而声明式编程语言的抽象程度更高,这类语言更倾向于以叙事的方式来描述程序逻辑,开发者无需关心语言背后在机器指令层面的实现细节,适合于业务快速迭代的场景。
不过语言不是一成不变的。编程语言一直在进化,它的进化速度绝对超过了自然语言的进化速度。
在抽象层面上,编程语言一直都停留在机器码 -> 汇编 -> 高级语言这三层上。而对于我们广大开发者来说,我们的目光一直聚焦在高级语言这一层上,所以,高级编程语言也慢慢成为了狭隘的编程语言(当然,这是一件好事,每一类人都应该各司其职做好自己的事情,不用过多担心指令架构、指令集差异带来的麻烦)。
谈到这里,不知你是否发现了一个规律:抽象越低的编程语言越接近计算机思维,而抽象越高越接近人脑思维。
是的。现代层出不穷的编程语言,往往都是在人脑、计算机思维之间的平衡做取舍。那些设计语言的专家们似乎在这个毫无硝烟的战场上博弈,彼此对立却又彼此借鉴。不过哪怕再博弈,按照人类自然语言的趋势来看,也几乎不可能出现一家独大的可能,就像人类目前也是汉语、英语等多种语言共存,即使世界语于 1887 年就被发明,但我们似乎从未见过谁说世界语。
既然高级编程语言那么多,对于有选择困难症的我们,又该做出何种选择呢?
工具
一提到选语言,估计你常听这么一句话,语言是工具。很长一段时间里,我也这么告诫自己,无所谓一门语言的优劣,它仅仅只是一门工具,而我需要做的就是将这门工具用好。语言是表达思想的载体,只要有了思想,无论是何种语言,都能表达。
可当我接触了越来越多的编程语言,对代码、指令、抽象有了更深入的理解之后,我推翻了这个想法,认识到了“语言只是工具”这个说法的狭隘性。
编程语言,显然不仅只是工具,它一定程度上桎梏了我们的思维。
举例来说,使用 Java 或者 C# 的人能够很轻易地想到对象的设计与封装,那是因为 Java 和 C# 就是以类作为基本的组织单位,无论你是否有意识地去做这件事,你都已经做了。而对于 C 和 JavaScript 的使用者来说,大家似乎更倾向于使用函数来进行封装。
抛开语言本身的优劣,这是一种思维的惯性,恰恰也印证了上面我谈到的,语言一定程度上桎梏了我们的思维。其实如果从人类语言的角度出发,一个人说中文和说英文的思维方式是大相径庭的,甚至一个人分别说方言和普通话给别人的感觉也像是两个人一样。
Rust
所以如果说思维是我们创造的出发点那么编程语言在表达思维的同时也在一定程度上桎梏了我们的思维。聊到这里终于到我们今天的主角——Rust这门编程语言出场了。
Rust 是什么?
Rust 是一门高度抽象、性能与安全并重的现代化高级编程语言。我学习、推崇它的主要原因有三点:
高度抽象、表达能力强,支持命令式、声明式、元编程、范型等多种编程范式;
强大的工程能力,安全与性能并重;
良好的底层能力,天然适合内核、数据库、网络。
Rust 很好地迎合了人类思维,对指令集进行了高度抽象,抽象后的表达力能让我们以更接近人类思维的视角去写代码,而 Rust 负责将我们的思维翻译为计算机语言,并且性能和安全得到了极大的保证。简单说就是,完美兼顾了一门语言的思想性和工具性。
仍以前面“选出一个数组中大于 100 的数字”为例,如果使用 Rust那么代码是这样的
let arr = vec![ 100, 105, 110, 99, 0 ]
let result = arr.iter().filter(n => n > 100).collect();
如此简洁的代码会不会带来性能损耗Rust 的答案是不会,甚至可以比 C 做到更快。
我们对应看三个小例子的实现思路/要点,来感受一下 Rust 的语言表达能力、工程能力和底层能力。
简单协程
Rust 可以无缝衔接到 C、汇编代码这样我们就可以跟下层的硬件打交道从而实现协程。
实现也很清晰。首先,定义出协程的上下文:
#[derive(Debug, Default)]
#[repr(C)]
struct Context {
rsp: u64, // rsp 寄存器
r15: u64,
r14: u64,
r13: u64,
r12: u64,
rbx: u64,
rbp: u64,
}
#[naked]
unsafe fn ctx_switch() {
// 注意16 进制
llvm_asm!(
"
mov %rsp, 0x00(%rdi)
mov %r15, 0x08(%rdi)
mov %r14, 0x10(%rdi)
mov %r13, 0x18(%rdi)
mov %r12, 0x20(%rdi)
mov %rbx, 0x28(%rdi)
mov %rbp, 0x30(%rdi)
mov 0x00(%rsi), %rsp
mov 0x08(%rsi), %r15
mov 0x10(%rsi), %r14
mov 0x18(%rsi), %r13
mov 0x20(%rsi), %r12
mov 0x28(%rsi), %rbx
mov 0x30(%rsi), %rbp
"
);
}
结构体 Context 保存了协程的运行上下文信息(寄存器数据),通过函数 ctx_switch当前协程就可以交出 CPU 使用权,下一个协程接管 CPU 并进入执行流。
然后我们给出协程的定义:
#[derive(Debug)]
struct Routine {
id: usize,
stack: Vec<u8>,
state: State,
ctx: Context,
}
协程 Routine 有自己唯一的 id、栈 stack、状态 state以及上下文 ctx。Routine 通过 spawn 函数创建一个就绪协程yield 函数会交出 CPU 执行权:
pub fn spawn(&mut self, f: fn()) {
// 找到一个可用的
// let avaliable = ....
let sz = avaliable.stack.len();
unsafe {
let stack_bottom = avaliable.stack.as_mut_ptr().offset(sz as isize); // 高地址内存是栈顶
let stack_aligned = (stack_bottom as usize & !15) as *mut u8;
std::ptr::write(stack_aligned.offset(-16) as *mut u64, guard as u64);
std::ptr::write(stack_aligned.offset(-24) as *mut u64, hello as u64);
std::ptr::write(stack_aligned.offset(-32) as *mut u64, f as u64);
avaliable.ctx.rsp = stack_aligned.offset(-32) as u64; // 16 字节对齐
}
avaliable.state = State::Ready;
}
pub fn r#yield(&mut self) -> bool {
// 找到一个 ready 的,然后让其运行
let mut pos = self.current;
//.....
self.routines[pos].state = State::Running;
let old_pos = self.current;
self.current = pos;
unsafe {
let old: *mut Context = &mut self.routines[old_pos].ctx;
let new: *const Context = &self.routines[pos].ctx;
llvm_asm!(
"mov $0, %rdi
mov $1, %rsi"::"r"(old), "r"(new)
);
ctx_switch();
}
self.routines.len() > 0
}
运行结果如下:
1 STARTING
routine: 1 counter: 0
2 STARTING
routine: 2 counter: 0
routine: 1 counter: 1
routine: 2 counter: 1
routine: 1 counter: 2
routine: 2 counter: 2
routine: 1 counter: 3
routine: 2 counter: 3
routine: 1 counter: 4
routine: 2 counter: 4
routine: 1 counter: 5
routine: 2 counter: 5
routine: 1 counter: 6
routine: 2 counter: 6
routine: 1 counter: 7
routine: 2 counter: 7
routine: 1 counter: 8
routine: 2 counter: 8
routine: 1 counter: 9
routine: 2 counter: 9
1 FINISHED
具体代码实现参考协程 。
简单内核
操作系统内核是一个极为庞大的工程,但是如果只是写个简单内核输出 Hello World那么 Rust 就能很快完成这个任务。你可以自己体验一下。
首先,添加依赖工具:
rustup component add llvm-tools-preview
cargo install bootimage
然后编辑 main.rs 文件输出一个 Hello World
#![no_std]
#![no_main]
use core::panic::PanicInfo;
static HELLO:&[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop{}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
然后编译、打包运行:
cargo bootimage
cargo run
运行结果如下:-
具体代码实现参考内核 。
简单网络协议栈
同操作系统一样,网络协议栈也是一个庞大的工程系统。但是借助 Rust 和其完备的生态,我们可以迅速完成一个小巧的 HTTP 协议栈。
首先,在数据链路层,我们定义 Mac 地址结构体:
#[derive(Debug)]
pub struct MacAddress([u8; 6]);
impl MacAddress {
pub fn new() -> MacAddress {
let mut octets: [u8; 6] = [0; 6];
rand::thread_rng().fill_bytes(&mut octets); // 1. 随机生成
octets[0] |= 0b_0000_0010; // 2
octets[1] &= 0b_1111_1110; // 3
MacAddress { 0: octets }
}
}
MacAddress 用来表示网卡的物理地址,此处的 new 函数通过随机数来生成随机的物理地址。
然后实现 DNS 域名解析函数,通过 IP 地址获取 MAC 地址,如下:
pub fn resolve(
dns_server_address: &str,
domain_name: &str,
) -> Result<Option<std::net::IpAddr>, Box<dyn Error>> {
let domain_name = Name::from_ascii(domain_name).map_err(DnsError::ParseDomainName)?;
let dns_server_address = format!("{}:53", dns_server_address);
let dns_server: SocketAddr = dns_server_address
.parse()
.map_err(DnsError::ParseDnsServerAddress)?;
// ....
let mut encoder = BinEncoder::new(&mut request_buffer);
request.emit(&mut encoder).map_err(DnsError::Encoding)?;
let _n_bytes_sent = localhost
.send_to(&request_buffer, dns_server)
.map_err(DnsError::Sending)?;
loop {
let (_b_bytes_recv, remote_port) = localhost
.recv_from(&mut response_buffer)
.map_err(DnsError::Receiving)?;
if remote_port == dns_server {
break;
}
}
let response = Message::from_vec(&response_buffer).map_err(DnsError::Decoding)?;
for answer in response.answers() {
if answer.record_type() == RecordType::A {
let resource = answer.rdata();
let server_ip = resource.to_ip_addr().expect("invalid IP address received");
return Ok(Some(server_ip));
}
}
Ok(None)
}
接着实现 HTTP 协议的 GET 方法:
pub fn get(
tap: TapInterface,
mac: EthernetAddress,
addr: IpAddr,
url: Url,
) -> Result<(), UpstreamError> {
let domain_name = url.host_str().ok_or(UpstreamError::InvalidUrl)?;
let neighbor_cache = NeighborCache::new(BTreeMap::new());
// TCP 缓冲区
let tcp_rx_buffer = TcpSocketBuffer::new(vec![0; 1024]);
let tcp_tx_buffer = TcpSocketBuffer::new(vec![0; 1024]);
let tcp_socket = TcpSocket::new(tcp_rx_buffer, tcp_tx_buffer);
let ip_addrs = [IpCidr::new(IpAddress::v4(192, 168, 42, 1), 24)];
let fd = tap.as_raw_fd();
let mut routes = Routes::new(BTreeMap::new());
let default_gateway = Ipv4Address::new(192, 168, 42, 100);
routes.add_default_ipv4_route(default_gateway).unwrap();
let mut iface = EthernetInterfaceBuilder::new(tap)
.ethernet_addr(mac)
.neighbor_cache(neighbor_cache)
.ip_addrs(ip_addrs)
.routes(routes)
.finalize();
let mut sockets = SocketSet::new(vec![]);
let tcp_handle = sockets.add(tcp_socket);
// HTTP 请求
let http_header = format!(
"GET {} HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n",
url.path(),
domain_name,
);
let mut state = HttpState::Connect;
'http: loop {
let timestamp = Instant::now();
match iface.poll(&mut sockets, timestamp) {
Ok(_) => {}
Err(smoltcp::Error::Unrecognized) => {}
Err(e) => {
eprintln!("error: {:?}", e);
}
}
{
let mut socket = sockets.get::<TcpSocket>(tcp_handle);
state = match state {
HttpState::Connect if !socket.is_active() => {
eprintln!("connecting");
socket.connect((addr, 80), random_port())?;
HttpState::Request
}
HttpState::Request if socket.may_send() => {
eprintln!("sending request");
socket.send_slice(http_header.as_ref())?;
HttpState::Response
}
HttpState::Response if socket.can_recv() => {
socket.recv(|raw_data| {
let output = String::from_utf8_lossy(raw_data);
println!("{}", output);
(raw_data.len(), ())
})?;
HttpState::Response
}
HttpState::Response if !socket.may_recv() => {
eprintln!("received complete response");
break 'http;
}
_ => state,
}
}
phy_wait(fd, iface.poll_delay(&sockets, timestamp)).expect("wait error");
}
Ok(())
}
最后在 main 函数中使用 HTTP GET 方法:
fn main() {
// ...
let tap = TapInterface::new(&tap_text).expect(
"error: unable to use <tap-device> as a \
network interface",
);
let domain_name = url.host_str().expect("domain name required");
let _dns_server: std::net::Ipv4Addr = dns_server_text.parse().expect(
"error: unable to parse <dns-server> as an \
IPv4 address",
);
let addr = dns::resolve(dns_server_text, domain_name).unwrap().unwrap();
let mac = ethernet::MacAddress::new().into();
http::get(tap, mac, addr, url).unwrap();
}
运行程序,结果如下:
$ ./target/debug/rget http://www.baidu.com tap-rust
HTTP/1.0 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 9508
Content-Type: text/html
具体代码实现参考协议栈 。
通过这三个简单的小例子,无论是协程、内核还是协议栈,这些听上去都很高大上的技术,在 Rust 强大的表现力、生态和底层能力面前显得如此简单和方便。
思维是出发点,语言是表达体,工具是媒介,而 Rust 完美兼顾了一门语言的思想性和工具性,赋予了我们极强的工程表达能力和完成能力。
总结
作为极其现代的语言Rust 集百家之长而成,将性能、安全、语言表达力都做到了极致,但同时也带来了巨大的学习曲线。
初学时,每天都要和编译器做斗争,每次编译都是满屏的错误信息;攻克一个陡坡后,发现后面有更大的陡坡,学习的道路似乎无穷无尽。那我们为什么要学习 Rust
这里引用左耳朵耗子的一句话:
如果你对 Rust 的概念认识得不完整,你完全写不出程序,那怕就是很简单的一段代码。这逼着程序员必须了解所有的概念才能编码。
Rust 是一个对开发者极其严格的语言,严格到你学的不扎实,就不能写程序,但这无疑也是一个巨大的机会,改掉你不好的编码习惯,锻炼你的思维,让你成为真正的大师。
聊到这里,你是否已经对 Rust 有了更深的认识和更多的激情,那么放手去做吧!期待你与 Rust 擦出更加明亮的火花!
参考资料
Writing an OS in Rust
green-threads-explained-in-200-lines-of-rust
https://github.com/PedroGao/rust-examples
《深入理解计算机系统》
《Rust in Action》
《硅谷来信》
《浪潮之巅》

View File

@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 永续之原Rust学习如何持续精进
你好,我是陈天。
首先,恭喜你完成了这门课程!
六月底,我确定了自己会在极客时间上出这个 Rust 的专栏。
其实以前我对这样子的付费课程不是太感冒,因为自己随性惯了,写公众号自由洒脱,想写就写,想停就停,一个主题写腻了还可以毫无理由地切换到另一个主题上。但一旦写付费的专栏签下合同,就意味着品味、质量、内容以及更新的速度都不能随心所欲,得按照人家的要求来。
最要命的是更新的速度——我没有专职做过文字工作者,想来和代码工作者性质类似,一些开创性工作的开始特别需要灵感,非常依赖妙手偶得的那个契机。这种不稳定的输出模式,遇到进度的压力,就很折磨人。所以之前很多机会我都婉拒了。
但这次思来想去,我还是接下了 Rust 第一课这个挑战。
大部分原因是我越来越喜爱 Rust 这门语言,想让更多的人也能爱上它,于是之前在公众号和 B 站上也做了不少输出。但这样的输出左一块右一块的没有一个完整的体系所以有这样一个机会来构建出我个人总结的Rust学习体系也许对大家的学习会有很大的帮助。
另外一部分原因也是出于我的私心。自从 2016 年《途客圈创业记》出版后,我就没有正式出版过东西,很多口头答应甚至签下合同的选题,也都因为各种原因被我终止或者搁置了。我特别想知道,自己究竟是否还能拿起笔写下严肃的可以流传更广、持续更久的文字。
可是——介绍一门语言的文字可以有持久的生命力么?
你一定有这个疑问。
撰写介绍一门编程语言的文字,却想让它拥有持久的生命力,这听上去似乎是痴人说梦。现代编程语言的进化速度相比二十年前,可谓是一日千里。就拿 Rust 来说,稳定的六周一个版本,三年一个版次,别说是拥有若干年的生命力了,就算是专栏连载的几个月,都会过去两三个版本,也就意味着有不少新鲜的东西被加入到语言中。
不过好在 Rust 极其注重向后兼容,也就意味着我现在介绍的代码,只要是 Rust 语言或者标准库中稳定的内容若干年后应该还是可以有效的。Rust 这种不停迭代却一直保持向后兼容的做法,让它相对于其它语言在教学上有一些优势,所以,撰写介绍 Rust 的文字,生命力会更加持久一些。
当然这还远远不够。让介绍一门编程语言的文字更持久的方式就是,从本原出发,帮助大家理解语言表层概念背后的思想或者机理,这也是这个专栏最核心的设计思路。
通用型计算机诞生后差不多七十年了,当时的冯诺依曼结构依然有效;从 C 语言诞生到现在也有快五十年了,编程语言处理内存的方式还是堆和栈,常用的算法和数据结构也还是那些。虽然编程语言在不断进化,但解决问题的主要手段还都是差不多的。
比如说,引用计数,你如果在之前学习的任何一门语言中弄明白了它的思路,那么理解 Rust 下的 Rc/Arc 也不在话下。所以,只要我们把基础知识夯实,很多看似难懂的问题,只不过是在同样本质上套了让人迷惑的外衣而已。
那么如何拨开迷雾抵达事物的本原呢?我的方法有两个:一曰问,二曰切。对,就是中医“望闻问切”后两个字。
问就是刨根追底,根据已有的认知,发出直击要害的疑问,这样才能为后续的探索(切)叩开大门。比如你知道引用计数通行的实现方法,也知道 Rust 的单一所有权机制把堆内存的生命周期和栈内存绑定在一起,栈在值在,栈亡值亡。
那么你稍微思考一下就会产生疑问Rc/Arc 又是怎么打破单一所有权机制,做到让堆上的内存跳脱了栈上内存的限制呢?问了这个问题,你就有机会往下“切”。
“切”是什么呢,就是深入查看源代码,顺着脉络找出问题的答案。初学者往往不看标准库的源码,实际上,看源代码是最能帮助你成长的。无论是学习一门语言,还是学习 Linux 内核或者别的什么,源码都是第一手资料。别人的分析讲得再好,也是嚼过的饭,受限于他的理解能力和表达能力,这口嚼过的饭还真不一定比你自己亲自上嘴更好下咽。
比如想知道上面Rc/Arc的问题自然要看 Rc::new 的源码实现:
pub fn new(value: T) -> Rc<T> {
// There is an implicit weak pointer owned by all the strong
// pointers, which ensures that the weak destructor never frees
// the allocation while the strong destructor is running, even
// if the weak pointer is stored inside the strong one.
Self::from_inner(
Box::leak(box RcBox { strong: Cell::new(1), weak: Cell::new(1), value }).into(),
)
}
不看不知道,一看吓一跳。可疑的 Box::leak 出现在我们眼前。这个 Box::leak 又是干什么的呢?顺着这个线索追溯下去,我们发现了一个宝贵的金矿(你可以回顾生命周期的那一讲)。
在追溯本原的基础上,我们还要学会分析问题和解决问题的正确方法。我觉得编程语言的学习不应该只局限于学习语法本身,更应该在这个过程中,不断提升自己学习知识和处理问题的能力。
如果你还记得 HashMap 那一讲,我们先是宏观介绍解决哈希冲突的主要思路,它是构建哈希表的核心算法;然后使用 transmute 来了解 Rust HashMap 的组织结构,通过 gdb 查看内存布局,再结合代码去找到 HashMap 构建和扩容的具体思路。
这样一层层剥茧抽丝,边学习,边探索,边总结,最终我们得到了对 Rust 哈希表非常扎实的掌握。这种掌握程度,哪怕你十年都不碰 Rust十年后有人问你 Rust 的哈希表怎么工作的,你也能回答个八九不离十。
我希望你能够掌握这种学习的方式这是终生受益的方式。2006 年,我在 Juniper 工作时,用类似的方式,把 ScreenOS 系统的数据平面的处理流程总结出来了,到现在很多细节我记忆犹新。
很多时候面试一些同学,详细询问他们三五年前设计和实现过的一些项目时,他们会答不上来,经常给出“这个项目太久了,我记不太清楚”这样的答复,让我觉得好奇怪。对我而言,只要是做过的项目、阅读过的代码,不管多久,都能回忆起很多细节,就好像它们是自己的一部分一样。
尽管快有 20 年没有碰,我还记得第一份工作中 OSPFv2 和 IGMPv3 协议的部分细节,知道 netlink 如何工作,也对 Linux VMM 管理的流程有一个基本印象。现在想来,可能就是我掌握了正确的学习方法而已。
所以,在这门介绍语言的课程中,我还夹带了很多方法论相关的私货,它们大多散落在文章的各个角落,除了刚刚谈到的分析问题/解决问题的方法外,还有阅读代码的方法、架构设计的方法、撰写和迭代接口的方法、撰写测试的方法、代码重构的方法等等。希望这些私货能够让你产生共鸣,结合你自己在职业生涯中总结出来的方法,更好地服务于你的学习和工作。
在撰写这个专栏的过程中我参考了不少书籍。比如《Programming Rust》、《Designing Data-intensive Applications》以及《Fundamentals of Software Architecture》。可惜 Jon Gjengset 的《Rust for Rustaceans》姗姗来迟否则这个专栏的水准可以更上一个台阶。
我们做软件开发的似乎到了一定年纪就不怎么阅读这样不好。毕加索说“good artists copy; great artists steal.”当你从一个人身上学习时,你在模仿;当你从一大群人身上学习时,你自己就慢慢融会贯通,成为大师。
所以不要指望学了这门Rust 第一课,就大功告成,这门课仅仅是一个把你接引至 Rust 世界的敲门砖,接下来你还要进一步从各个方面学习和夯实更多的知识。
就像我回答一个读者的问题所说的:很多时候,我们缺乏的不是对 Rust 知识的理解,更多是对软件开发更广阔知识的理解。所以,不要拘泥于 Rust 本身,对你自己感兴趣的,以及你未来会涉猎的场景广泛阅读、深度思考。
伴随着学习,阅读,思考,我们还要广泛地实践。不要一有问题就求助,想想看,自己能不能构造足够简单的代码来帮助解决问题。
比如有人问HTTP/2 是怎么工作的?这样的问题,你除了可以看 RFC阅读别人总结的经验还可以动动手几行代码就可以获得很多信息。比如
use tracing::info;
use tracing_subscriber::EnvFilter;
fn main() {
tracing_subscriber::fmt::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let url = "<https://www.rust-lang.org/>";
let _body = reqwest::blocking::get(url).unwrap().text().unwrap();
info!("Fetching url: {}", url);
}
这段代码相信你肯定能写得出来,但你是否尝试过 RUST_LOG=debug 甚至 RUST_LOG=trace 来看看输出的日志呢?又有没有尝试着顺着日志的脉络,去分析涉及的库呢?
下面是这几行代码 RUST_LOG=debug 的输出,可以让你看到 HTTP/2 基本的运作方式,我建议你试试 RUST_LOG=trace内容太多就不贴了如果你能搞清楚输出的信息那么 Rust 下用 hyper 处理 HTTP/2 的主流程你就比较明白了。
RUST_LOG=debug cargo run --quiet
2021-12-12T21:28:00.612897Z DEBUG reqwest::connect: starting new connection: <https://www.rust-lang.org/>
2021-12-12T21:28:00.613124Z DEBUG hyper::client::connect::dns: resolving host="www.rust-lang.org"
2021-12-12T21:28:00.629392Z DEBUG hyper::client::connect::http: connecting to 13.224.7.43:443
2021-12-12T21:28:00.641156Z DEBUG hyper::client::connect::http: connected to 13.224.7.43:443
2021-12-12T21:28:00.641346Z DEBUG rustls::client::hs: No cached session for DnsName(DnsName(DnsName("www.rust-lang.org")))
2021-12-12T21:28:00.641683Z DEBUG rustls::client::hs: Not resuming any session
2021-12-12T21:28:00.656251Z DEBUG rustls::client::hs: Using ciphersuite Tls13(Tls13CipherSuite { suite: TLS13_AES_128_GCM_SHA256, bulk: Aes128Gcm })
2021-12-12T21:28:00.656754Z DEBUG rustls::client::tls13: Not resuming
2021-12-12T21:28:00.657046Z DEBUG rustls::client::tls13: TLS1.3 encrypted extensions: [ServerNameAck, Protocols([PayloadU8([104, 50])])]
2021-12-12T21:28:00.657151Z DEBUG rustls::client::hs: ALPN protocol is Some(b"h2")
2021-12-12T21:28:00.658435Z DEBUG h2::client: binding client connection
2021-12-12T21:28:00.658526Z DEBUG h2::client: client connection bound
2021-12-12T21:28:00.658602Z DEBUG h2::codec::framed_write: send frame=Settings { flags: (0x0), enable_push: 0, initial_window_size: 2097152, max_frame_size: 16384 }
2021-12-12T21:28:00.659062Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=WindowUpdate { stream_id: StreamId(0), size_increment: 5177345 }
2021-12-12T21:28:00.659327Z DEBUG hyper::client::pool: pooling idle connection for ("https", www.rust-lang.org)
2021-12-12T21:28:00.659674Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=Headers { stream_id: StreamId(1), flags: (0x5: END_HEADERS | END_STREAM) }
2021-12-12T21:28:00.672087Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Settings { flags: (0x0), max_concurrent_streams: 128, initial_window_size: 65536, max_frame_size: 16777215 }
2021-12-12T21:28:00.672173Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=Settings { flags: (0x1: ACK) }
2021-12-12T21:28:00.672244Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=WindowUpdate { stream_id: StreamId(0), size_increment: 2147418112 }
2021-12-12T21:28:00.672308Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Settings { flags: (0x1: ACK) }
2021-12-12T21:28:00.672351Z DEBUG Connection{peer=Client}: h2::proto::settings: received settings ACK; applying Settings { flags: (0x0), enable_push: 0, initial_window_size: 2097152, max_frame_size: 16384 }
2021-12-12T21:28:00.956751Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Headers { stream_id: StreamId(1), flags: (0x4: END_HEADERS) }
2021-12-12T21:28:00.956921Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957015Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957079Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957316Z DEBUG reqwest::async_impl::client: response '200 OK' for <https://www.rust-lang.org/>
2021-12-12T21:28:01.018665Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:01.018885Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1), flags: (0x1: END_STREAM) }
2021-12-12T21:28:01.020158Z INFO http2: Fetching url: <https://www.rust-lang.org/>
所以,很多时候,知识就在我们身边,我们写一写代码就能获取。
在这个过程中,你自己思考之后撰写的探索性的代码、你分析输出过程中付出的思考和深度的阅读,以及最后在梳理过程中进行的总结,都会让知识牢牢变成你自己的。
最后我们聊一聊写代码这个事。
学习任何语言最重要的步骤都是用学到的知识解决实际的问题。Rust 能不能胜任你需要完成的各种任务?大概率能。但你能不能用 Rust 来完成这些任务?不一定。每个十指俱全的人都能学习弹钢琴,但不是每个学弹钢琴的人都能达到十级的水平。这其中现实和理想间巨大的鸿沟就是“刻意练习”。
想要成为 Rust 专家,想让 Rust 成为你职业生涯中的一项重要技能刻意练习必不可少需要不断地撰写代码。的确Rust 的所有权和生命周期学习和使用起来让人难于理解所有权、生命周期跟类型系统包括泛型、trait以及异步开发结合起来更是障碍重重但通过不断学习和不断练习你一定会发现它们不过是你的一段伟大旅程中越过的一个小山丘而已。
最后的最后,估计很多同学都是在艰难斗争、默默学习,在专栏要结束的今天,欢迎你在留言区留言,我非常希望能听到你的声音,听听你学习这个专栏的感受和收获,见到你的身影。点这里还可以提出你对课程的反馈与建议。
感谢你选择我的 Rust 第一课。感谢你陪我们一路走到这里。接下来,就看你的了。
“Go where you must go, and hope!”— Gandalf

View File

@ -0,0 +1,124 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 入门Spark你需要学会“三步走”
你好我是吴磊欢迎和我一起入门学习Spark。
在过去的7年里我一直在围绕着Spark来规划我的职业发展。2014年Spark以“星火燎原”之势席卷了整个大数据生态圈正是在那个时候我结识了Spark。起初怀揣着强烈的好奇心我花了一个星期用Spark重写了公司IBM的ETL任务。
让我颇为惊讶的是Spark版本的ETL任务在执行性能上提升了一个数量级。从那以后我便深深地着迷于Spark孜孜不倦、乐此不疲地学习、实践与Spark有关的一切从官方文档到技术博客从源代码再到最佳实践从动手实验再到大规模应用在这个过程里
在IBM我用Spark Streaming构建了流处理应用帮助业务人员去实时分析用户行为。
在联想研究院我用Spark SQL + Hive搭建的公司级数仓服务于所有业务部门。
在微博我基于Spark MLlib来构建微博机器学习框架配置化的开发框架让上百位算法工程师从繁重的数据处理、特征工程、样本工程中解脱出来把宝贵的精力和时间投入到了算法研究与模型调优上来。
在FreeWheel在所有的机器学习项目中我们使用Spark进行数据探索、数据处理、特征工程、样本工程与模型训练将一个又一个机器学习项目落地到业务中。
为了把Spark吃得更透在日常的工作中我热衷于把学过的知识、习得的技巧、踩过的坑、绕过的弯路付诸笔头。通过这种“学、用、写”不断迭代的学习方式我把零散的开发技巧与知识点逐渐地归纳成了结构化的知识体系。
在2021年的3月份我与极客时间合作了《Spark性能调优实战》这一专栏把我积累的与性能调优有关的技巧、心得、最佳实践分享给有需要的同学。
让我欣慰的是专栏的内容受到了同学们的广泛好评有不少同学反馈采用专栏中的调优技巧Spark作业的执行性能提升了好几倍。但同时也有一些同学反馈自己才入门大数据专栏中的很多内容表示看不懂。
实际上我身边也有不少同学他们有的科班出身于机器学习、人工智能有的准备从后端开发、DBA甚至是其他行业转型做大数据开发有的想基于开源框架构建企业级数据仓库都面临着如何快速入门Spark的难题。
“快”和“全”让Spark成了互联网公司的标配
不过你可能会好奇“Spark还有那么火吗会不会已经过时了”实际上历经十多年的发展Spark已经由当初的“大数据新秀”成长为数据应用领域的中流砥柱。在数据科学与机器学习魔力象限当中IT研究与咨询公司Gartner连续3年2018 ~ 2020将DatabricksSpark云原生商业版本提名为Market Leader。
不仅如此凭借其自身的诸多优势Spark早已成为绝大多数互联网公司的标配。比如字节跳动基于 Spark 构建数据仓库服务着旗下几乎所有的产品线包括抖音、今日头条、西瓜视频、火山视频比如美团早在2014年就引入了Spark并逐渐将其覆盖到美团App、美团外卖、美团打车等核心产品再比如Netflix基于Spark构建端到端的机器学习流水线围绕着Spark打造服务于超过两亿订阅用户的推荐引擎。
事实上任何一家互联网公司都离不开推荐、广告、搜索这3类典型业务场景。推荐与搜索帮助企业引流、提升用户体验、维持用户黏性、拓展用户增长而广告业务则用于将流量变现是互联网公司最重要的商业模式之一。而在这些业务场景背后的技术栈当中你都能看到Spark的身影它或是用于ETL与流处理、或是用于构建企业级数据分析平台、或是用于打造端到端的机器学习流水线。
那么我们不禁要问“在发展迅猛的数据应用领域同类竞品可以说是层出不穷、日新月异Spark何以傲视群雄在鹰视狼顾的厮杀中脱颖而出并能持久地立于不败之地”在我看来这主要是得益于Spark的两大优势快、全。
有两个方面一个是开发效率快另一个是执行效率快。Spark支持多种开发语言如Python、Java、Scala、R和SQL同时提供了种类丰富的开发算子如RDD、DataFrame、Dataset。这些特性让开发者能够像搭积木一样信手拈来、驾轻就熟地完成数据应用开发。
在我的身边有很多不具备大数据背景却需要从零开始用Spark做开发的同学。最开始他们往往需要“照葫芦画瓢”、参考别人的代码实现才能完成自己的工作。但是经过短短3个月的强化练习之后绝大多数同学都能够独当一面、熟练地实现各式各样的业务需求。而这自然要归功于Spark框架本身超高的开发效率。
再者凭借Spark Core和Spark SQL这两个并驾齐驱的计算引擎我们开发出的数据应用并不需要太多的调整或是优化就能享有不错的执行性能。
而这主要得益于Spark社区对于底层计算引擎的持续打磨与优化才让开发者能够把精力专注于业务逻辑实现而不必关心框架层面的设计细节。
说完了Spark的“快”接下来我们再来说说它的“全”。全指的是Spark在计算场景的支持上非常全面。我们知道在数据应用领域有如下几类计算场景它们分别是批处理、流计算、数据分析、机器学习和图计算。
批处理作为大数据的基础,自然不必多说了。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。而在人工智能火热的当下,数据分析与机器学习也是我们必须要关注的重中之重。
对于这几类计算场景Spark提供了丰富的子框架予以支持。比如针对流计算的Structured Streaming用于数据分析的Spark SQL服务于机器学习的Spark MLlib等等。Spark全方位的场景支持让开发者“足不出户”、在同一套计算框架之内即可实现不同类型的数据应用从而避免为了实现不同类型的数据应用而疲于奔命地追逐各式各样的新技术、新框架。
不难发现Spark集众多优势于一身在互联网又有着极其深远的影响力对于想要在数据应用领域有所建树的同学来说Spark可以说是一门必修课。
不管你是专注于应用开发与二次开发的大数据工程师还是越来越火热的数据分析师、数据科学家、以及机器学习算法研究员Spark都是你必须要掌握的一项傍身之计。
不过尽管Spark优势众多但入门Spark却不是一件容易的事情。身边的同学经常有这样的感叹
网上的学习资料实在太多,但大部分都是零星的知识点,很难构建结构化的知识体系;
Spark相关的书籍其实也不少但多是按部就班、照本宣科地讲原理看不下去
要想学习Spark还要先学ScalaScala语法晦涩难懂直接劝退
开发算子太多了,记不住,来了新的业务需求,不知道该从哪里下手;
……
既然Spark是数据应用开发者在职业发展当中必需的一环而入门Spark又有这样那样的难处和痛点那么我们到底该如何入门Spark呢
如何入门Spark
如果把Spark比作是公路赛车的话那么我们每一个开发者就是准备上车驾驶的赛车手。要想开好这辆赛车那么第一步我们首先要熟悉车辆驾驶的基本操作比如挡位怎么挂油门、离合、刹车踏板分别在什么地方等等。
再者,为了发挥出赛车的性能优势,我们得了解赛车的工作原理,比如它的驱动系统、刹车系统等等。只有摸清了它的工作原理,我们才能灵活地操纵油、离、刹之间的排列组合。
最后,在掌握了赛车的基本操作和工作原理之后,对于不同的地形,比如公路、山路、沙漠等等,我们还要总结出针对不同驾驶场景的一般套路。遵循这样的三步走,我们才能从一个赛车小白,逐渐化身为资深赛车手。
和学习驾驶赛车一样入门Spark也需要这样的“三步走”。第一步就像是需要熟悉赛车的基本操作我们需要掌握Spark常用的开发API与开发算子。毕竟通过这些API与开发算子我们才能启动并驱使Spark的分布式计算引擎。
接着要想让Spark这台车子跑得稳我们必须要深入理解它的工作原理才行。因此在第二步我会为你讲解Spark的核心原理。
第三步就像是应对赛车的不同驾驶场景我们需要了解并熟悉Spark不同的计算子框架Spark SQL、Spark MLlib和Structured Streaming来应对不同的数据应用场景比如数据分析、机器学习和流计算。
与三步走相对应我把这门课设计成了4个模块其中第一个模块是基础知识模块我会专注于三步走的前两步也即熟悉开发API和吃透核心原理。在后面的三个模块中我会依次讲解Spark应对不同数据场景的计算子框架分别是Spark SQL、Spark MLlib和Structured Streaming。由于图计算框架GraphFrames在工业界的应用较少因此咱们的课程不包含这部分内容的介绍。
这四个模块和“三步走”的关系如下图所示:
从图中你可以看到由于在这三种子框架中Spark SQL在扮演数据分析子框架这个角色的同时还是Spark新一代的优化引擎其他子框架都能共享Spark SQL带来的“性能红利”所以我在讲解Spark SQL的时候也会涉及一些第一步、第二步中的基本操作和原理介绍。
在这四个模块中我们都会从一个小项目入手由浅入深、循序渐进地讲解项目涉及的算子、开发API、工作原理与优化技巧。尽管每个项目给出的代码都是由Scala实现的但你完全不用担心我会对代码逐句地进行注释提供“保姆级”的代码解释。
第一个模块是基础知识。
在这个模块中我们会从一个叫作“Word Count”的小项目开始。以Word Count的计算逻辑为线索我们会去详细地讲解RDD常用算子的含义、用法、注意事项与适用场景让你一站式掌握RDD算子我还会用一个又一个有趣的故事以轻松诙谐、深入浅出的方式为你讲解Spark核心原理包括RDD编程模型、Spark进程模型、调度系统、存储系统、Shuffle管理、内存管理等等从而让你像读小说一样去弄懂Spark。
第二个模块在讲Spark SQL时我首先会从“小汽车摇号”这个小项目入手带你熟悉Spark SQL开发API。与此同时依托这个小项目我会为你讲解Spark SQL的核心原理与优化过程。最后我们再重点介绍Spark SQL与数据分析有关的部分如数据的转换、清洗、关联、分组、聚合、排序等等。
在第三个模块我们会学习Spark机器学习子框架Spark MLlib。
在这个模块中我们会从“房价预测”这个小项目入手初步了解机器学习中的回归模型、以及Spark MLlib的基本用法。我还会为你介绍机器学习的一般场景会带你一起深入学习Spark MLlib丰富的特征处理函数细数Spark MLlib都支持哪些模型与算法并学习构建端到端的机器学习流水线。最后我还会讲Spark + XGBoost集成是如何帮助开发者应对大多数的回归与分类问题。
在课程的最后一部分我们一起来学习Spark的流处理框架Structured Streaming。
在这个模块中我们将重点讲解Structured Streaming如何同时保证语义一致性与数据一致性以及如何应对流处理中的数据关联并通过Kafka + Spark这对“Couple”的系统集成来演示流处理中的典型计算场景。
经过“熟悉开发API、吃透核心原理与玩转子框架”这三步走之后你就建立了属于自己的Spark知识体系完全跨进了Spark应用开发的大门。
对于绝大多数的数据应用需求来说,我相信你都能够游刃有余地做到灵活应对,分分钟交付一个满足业务需求、运行稳定、且执行性能良好的分布式应用。
最后欢迎你在这里畅所欲言提出你的困惑和疑问也欢迎多多给我留言你们的鼓励是我的动力。三步走的路线已经规划完毕让我们一起携手并进、轻松而又愉快地完成Spark的入门之旅吧
掌握了Spark这项傍身之计我坚信它可以让你在笔试、面试或是日常的工作中脱颖而出从而让Spark为你的职业发展增光添彩

View File

@ -0,0 +1,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 Spark从“大数据的Hello World”开始
你好,我是吴磊。
从这节课开始我们先来学习Spark的“基础知识”模块对Spark的概念和核心原理先做一个整体的了解。我并不会从RDD、DAG这些基本概念给你讲起。坦白地说这些抽象的概念枯燥而又乏味对于刚开始接触Spark的你来说很难学进去。因此我们不妨反其道而行之先从实战入手用一个小例子来直观地认识Spark看看Spark都能做些什么。
这就好比我们学习一门新的编程语言往往都是从“Hello World”开始。我还记得刚刚学编程那会屏幕上打印出的“Hello World”足足让我兴奋了一整天让我莫名地有一种“I can change the world”的冲动。
今天这一讲我们就从“大数据的Hello World”开始去学习怎么在Spark之上做应用开发。不过“大数据的Hello World”并不是把字符串打印到屏幕上这么简单而是要先对文件中的单词做统计计数然后再打印出频次最高的5个单词江湖人称“Word Count”。
之所以会选择Word Count作为我们迈入Spark门槛的第一个项目主要有两个原因一是Word Count场景比较简单、容易理解二是Word Count麻雀虽小但五脏俱全一个小小的Word Count就能够牵引出Spark许多的核心原理帮助我们快速入门。
好啦话不多说下面我们正式开启Word Count之旅。
准备工作
巧妇难为无米之炊要做Word Count我们得先把源文件准备好。
咱们做Word Count的初衷是学习Spark因此源文件的内容无足轻重。这里我提取了Wikipedia中对Spark的介绍来做我们的源文件。我把它保存到了与课程配套的GitHub项目中并把它命名为“wikiOfSpark.txt”。你可以从这里下载它。
为了跑通Word Count实例我们还需要在本地Local部署Spark运行环境。这里的“本地”指的是你手头能够获取到的任何计算资源比如服务器、台式机或是笔记本电脑。
在本地部署Spark运行环境非常简单即便你从来没有和Spark打过交道也不必担心。只需要下面这3个步骤我们就可以完成Spark的本地部署了。
下载安装包从Spark官网下载安装包选择最新的预编译版本即可
解压解压Spark安装包到任意本地目录
配置:将“${解压目录}/bin”配置到PATH环境变量。
我这里给你准备了一个本地部署的小视频,你可以直观地感受一下。
接下来我们确认一下Spark是否部署成功。打开命令行终端敲入“spark-shell version”命令如果该命令能成功地打印出Spark版本号就表示我们大功告成了就像这样
在后续的实战中我们会用spark-shell来演示Word Count的执行过程。spark-shell是提交Spark作业众多方式中的一种我们在后续的课程中还会展开介绍这里你不妨暂时把它当做是Spark中的Linux shell。spark-shell提供交互式的运行环境REPLRead-Evaluate-Print-Loop以“所见即所得”的方式让开发者在提交源代码之后就可以迅速地获取执行结果。
不过需要注意的是spark-shell在运行的时候依赖于Java和Scala语言环境。因此为了保证spark-shell的成功启动你需要在本地预装Java与Scala。好消息是关于Java与Scala的安装网上的资料非常丰富你可以参考那些资料来进行安装咱们在本讲就不再赘述Java与Scala的安装步骤啦。
梳理Word Count的计算步骤
做了一番准备之后接下来我们就可以开始写代码了。不过在“下手”之前咱们不妨一起梳理下Word Count的计算步骤先做到心中有数然后再垒代码也不迟。
之前我们提到Word Count的初衷是对文件中的单词做统计计数打印出频次最高的5个词汇。那么Word Count的第一步就很明显了当然是得读取文件的内容不然咱们统计什么呢
我们准备好的文件是wikiOfSpark.txt它以纯文本的方式记录了关于Spark的简单介绍我摘取了其中的部分内容给你看一下
我们知道文件的读取往往是以行Line为单位的。不难发现wikiOfSpark.txt的每一行都包含多个单词。
我们要是以“单词”作为粒度做计数就需要对每一行的文本做分词。分词过后文件中的每一句话都被打散成了一个个单词。这样一来我们就可以按照单词做分组计数了。这就是Word Count的计算过程主要包含如下3个步骤
读取内容调用Spark文件读取API加载wikiOfSpark.txt文件内容
分词:以行为单位,把句子打散为单词;
分组计数:按照单词做分组计数。
明确了计算步骤后接下来我们就可以调用Spark开发API对这些步骤进行代码实现从而完成Word Count的应用开发。
众所周知Spark支持种类丰富的开发语言如Scala、Java、Python等等。你可以结合个人偏好和开发习惯任意选择其中的一种进行开发。尽管不同语言的开发API在语法上有着细微的差异但不论是功能方面、还是性能方面Spark对于每一种语言的支持都是一致的。换句话说同样是Word Count你用Scala实现也行用Python实现也可以两份代码的执行结果是一致的。不仅如此在同样的计算资源下两份代码的执行效率也是一样的。
因此就Word Count这个示例来说开发语言不是重点我们不妨选择Scala。你可能会说“我本来对Spark就不熟更没有接触过Scala一上来就用Scala演示Spark应用代码理解起来会不会很困难
其实大可不必担心Scala语法比较简洁Word Count的Scala实现不超过10行代码。再者对于Word Count中的每一行Scala代码我会带着你手把手、逐行地进行讲解和分析。我相信跟着我过完一遍代码之后你能很快地把它“翻译”成你熟悉的语言比如Java或Python。另外绝大多数的Spark 源码都是由 Scala 实现的接触并了解一些Scala的基本语法有利于你后续阅读、学习Spark源代码。
Word Count代码实现
选定了语言接下来我们就按照读取内容、分词、分组计数这三步来看看Word Count具体怎么实现。
第一步,读取内容
首先我们调用SparkContext的textFile方法读取源文件也就是wikiOfSpark.txt代码如下表所示
import org.apache.spark.rdd.RDD
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
在这段代码中你可能会发现3个新概念分别是spark、sparkContext和RDD。
其中spark和sparkContext分别是两种不同的开发入口实例
spark是开发入口SparkSession实例InstanceSparkSession在spark-shell中会由系统自动创建
sparkContext是开发入口SparkContext实例。
在Spark版本演进的过程中从2.0版本开始SparkSession取代了SparkContext成为统一的开发入口。换句话说要开发Spark应用你必须先创建SparkSession。关于SparkSession和SparkContext我会在后续的课程做更详细的介绍这里你只要记住它们是必需的开发入口就可以了。
我们再来看看RDDRDD的全称是Resilient Distributed Dataset意思是“弹性分布式数据集”。RDD是Spark对于分布式数据的统一抽象它定义了一系列分布式数据的基本属性与处理方法。关于RDD的定义、内涵与作用我们留到[下一讲]再去展开。
在这里你不妨先简单地把RDD理解成“数组”比如代码中的lineRDD变量它的类型是RDD[String]你可以暂时把它当成元素类型是String的数组数组的每个元素都是文件中的一行字符串。
获取到文件内容之后,下一步我们就要做分词了。
第二步,分词
“分词”就是把“数组”的行元素打散为单词。要实现这一点我们可以调用RDD的flatMap方法来完成。flatMap操作在逻辑上可以分成两个步骤映射和展平。
这两个步骤是什么意思呢我们还是结合Word Count的例子来看
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
要把lineRDD的行元素转换为单词我们得先用分隔符对每个行元素进行分割Split咱们这里的分隔符是空格。
分割之后每个行元素就都变成了单词数组元素类型也从String变成了Array[String],像这样以元素为单位进行转换的操作,统一称作“映射”。
映射过后RDD类型由原来的RDD[String]变为RDD[Array[String]]。如果把RDD[String]看成是“数组”的话那么RDD[Array[String]]就是一个“二维数组”,它的每一个元素都是单词。
为了后续对单词做分组,我们还需要对这个“二维数组”做展平,也就是去掉内层的嵌套结构,把“二维数组”还原成“一维数组”,如下图所示。
就这样在flatMap算子的作用下原来以行为元素的lineRDD转换成了以单词为元素的wordRDD。
不过值得注意的是我们用“空格”去分割句子有可能会产生空字符串。所以在完成“映射”和“展平”之后对于这样的“单词”我们要把其中的空字符串都过滤掉这里我们调用RDD的filter方法来过滤
// 过滤掉空字符串
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
这样一来我们在分词阶段就得到了过滤掉空字符串之后的单词“数组”类型是RDD[String]。接下来,我们就可以准备做分组计数了。
第三步,分组计数
在RDD的开发框架下聚合类操作如计数、求和、求均值需要依赖键值对Key Value Pair类型的数据元素也就是KeyValue形式的“数组”元素。
因此在调用聚合算子做分组计数之前我们要先把RDD元素转换为KeyValue的形式也就是把RDD[String]映射成RDD[(String, Int)]。
其中我们统一把所有的Value置为1。这样一来对于同一个的单词在后续的计数运算中我们只要对Value做累加即可就像这样
下面是对应的代码:
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
这样一来RDD就由原来存储String元素的cleanWordRDD转换为了存储StringInt的kvRDD。
完成了形式的转换之后我们就该正式做分组计数了。分组计数其实是两个步骤也就是先“分组”再“计数”。下面我们使用聚合算子reduceByKey来同时完成分组和计数这两个操作。
对于kvRDD这个键值对“数组”reduceByKey先是按照Key也就是单词来做分组分组之后每个单词都有一个与之对应的Value列表。然后根据用户提供的聚合函数对同一个Key的所有Value做reduce运算。
这里的reduce你可以理解成是一种计算步骤或是一种计算方法。当我们给定聚合函数后它会用折叠的方式把包含多个元素的列表转换为单个元素值从而统计出不同元素的数量。
在Word Count的示例中我们调用reduceByKey实现分组计算的代码如下
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
可以看到我们传递给reduceByKey算子的聚合函数是(x, y) => x + y也就是累加函数。因此在每个单词分组之后reduce会使用累加函数依次折叠计算Value列表中的所有元素最终把元素列表转换为单词的频次。对于任意一个单词来说reduce的计算过程都是一样的如下图所示。
reduceByKey完成计算之后我们得到的依然是类型为RDD[(String, Int)]的RDD。不过与kvRDD不同wordCounts元素的Value值记录的是每个单词的统计词频。到此为止我们就完成了Word Count主逻辑的开发与实现。
在程序的最后我们还要把wordCounts按照词频做排序并把词频最高的5个单词打印到屏幕上代码如下所示。
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
代码执行
应用开发完成之后我们就可以把代码丢进已经准备好的本地Spark部署环境里啦。首先我们打开命令行终端Terminal敲入“spark-shell”打开交互式运行环境如下图所示。
然后把我们开发好的代码依次敲入spark-shell。为了方便你操作我把完整的代码实现整理到下面了
import org.apache.spark.rdd.RDD
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
我们把上面的代码依次敲入到spark-shell之后spark-shell最终会把词频最高的5个单词打印到屏幕上
在Wikipedia的Spark介绍文本中词频最高的单词分别是the、Spark、a、and和of除了“Spark”之外其他4个单词都是常用的停用词Stop Word因此它们几个高居榜首也就不足为怪了。
好啦到此为止我们在Spark之上完成了“大数据领域Hello World”的开发与实现恭喜你跨入大数据开发的大门
重点回顾
今天这一讲我们围绕着Word Count初步探索并体验了Spark应用开发。你首先需要掌握的是Spark的本地部署从而可以通过spark-shell来迅速熟悉Spark获得对Spark的“第一印象”。要在本地部署Spark你需要遵循3个步骤
从Spark官网下载安装包选择最新的预编译版本即可
解压Spark安装包到任意本地目录
将“${解压目录}/bin”配置到PATH环境变量。
然后我们一起分析并实现了入门Spark的第一个应用程序Word Count。在我们的例子中Word Count要完成的计算任务是先对文件中的单词做统计计数然后再打印出频次最高的5个单词。它的实现过程分为3个步骤
读取内容调用Spark文件读取API加载wikiOfSpark.txt文件内容
分词:以行为单位,把句子打散为单词;
分组计数:按照单词做分组计数。
也许你对RDD API还不熟悉甚至从未接触过Scala不过没关系完成了这次“大数据的Hello World”开发之旅你就已经踏上了新的征程。在接下来的课程里让我们携手并肩像探索新大陆一样一层一层地剥开Spark的神秘面纱加油
每课一练
在Word Count的代码实现中我们用到了多种多样的RDD算子如map、filter、flatMap和reduceByKey除了这些算子以外你知道还有哪些常用的RDD算子吗提示可以结合官网去查找
另外,你能说说,以上这些算子都有哪些共性或是共同点吗?
欢迎你把答案分享到评论区,我在评论区等你。
如果这一讲对你有帮助,也欢迎你分享给自己的朋友,我们下一讲再见!

View File

@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 RDD与编程模型延迟计算是怎么回事
你好,我是吴磊。
在上一讲我们一起开发了一个Word Count小应用并把它敲入到spark-shell中去执行。Word Count的计算步骤非常简单首先是读取数据源然后是做分词最后做分组计数、并把词频最高的几个词汇打印到屏幕上。
如果你也动手实践了这个示例你可能会发现在spark-shell的REPL里所有代码都是立即返回、瞬间就执行完毕了相比之下只有最后一行代码花了好长时间才在屏幕上打印出the、Spark、a、and和of这几个单词。
针对这个现象你可能会觉得很奇怪“读取数据源、分组计数应该是最耗时的步骤为什么它们瞬间就返回了呢打印单词应该是瞬间的事为什么这一步反而是最耗时的呢”要解答这个疑惑我们还是得从RDD说起。
什么是RDD
为什么非要从RDD说起呢首先RDD是构建Spark分布式内存计算引擎的基石很多Spark核心概念与核心组件如DAG和调度系统都衍生自RDD。因此深入理解RDD有利于你更全面、系统地学习 Spark 的工作原理。
其次尽管RDD API使用频率越来越低绝大多数人也都已经习惯于DataFrame和Dataset API但是无论采用哪种API或是哪种开发语言你的应用在Spark内部最终都会转化为RDD之上的分布式计算。换句话说如果你想要对Spark作业有更好的把握前提是你要对RDD足够了解。
既然RDD如此重要那么它到底是什么呢用一句话来概括RDD是一种抽象是Spark对于分布式数据集的抽象它用于囊括所有内存中和磁盘中的分布式数据实体。
在[上一讲]中我们把RDD看作是数组咱们不妨延续这个思路通过对比RDD与数组之间的差异认识一下RDD。
我列了一个表做了一下RDD和数组对比你可以先扫一眼
我在表中从四个方面对数组和RDD进行了对比现在我来详细解释一下。
首先就概念本身来说数组是实体它是一种存储同类元素的数据结构而RDD是一种抽象它所囊括的是分布式计算环境中的分布式数据集。
因此这两者第二方面的不同就是在活动范围数组的“活动范围”很窄仅限于单个计算节点的某个进程内而RDD代表的数据集是跨进程、跨节点的它的“活动范围”是整个集群。
至于数组和RDD的第三个不同则是在数据定位方面。在数组中承载数据的基本单元是元素而RDD中承载数据的基本单元是数据分片。在分布式计算环境中一份完整的数据集会按照某种规则切割成多份数据分片。这些数据分片被均匀地分发给集群内不同的计算节点和执行进程从而实现分布式并行计算。
通过以上对比不难发现数据分片Partitions是RDD抽象的重要属性之一。在初步认识了RDD之后接下来咱们换个视角从RDD的重要属性出发去进一步深入理解RDD。要想吃透RDD我们必须掌握它的4大属性
partitions数据分片
partitioner分片切割规则
dependenciesRDD依赖
compute转换函数
如果单从理论出发、照本宣科地去讲这4大属性未免过于枯燥、乏味、没意思所以我们从一个制作薯片的故事开始去更好地理解RDD的4大属性。
从薯片的加工流程看RDD的4大属性
在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片3 种不同尺寸的薯片分别被发往第 1、2、3 条流水线。具体流程如下图所示。
好了,故事讲完了。那如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比 Spark 分布式计算,会有哪些有趣的发现呢?
显然这里的每一种食材形态如“带泥土豆”、“干净土豆”、“土豆片”等都可以看成是一个个RDD。而薯片的制作过程实际上就是不同食材形态的转换过程。
起初,工人们从麻袋中把“带泥土豆”加载到流水线,这些土豆经过清洗之后,摇身一变,成了“干净土豆”。接下来,流水线上的切片机再把“干净土豆”切成“土豆片”,然后紧接着把这些土豆片放进烤箱。最终,土豆片烤熟之后,就变成了可以放心食用的即食薯片。
通过分析我们不难发现不同食材形态之间的转换过程与Word Count中不同RDD之间的转换过程如出一辙。
所以接下来我们就结合薯片的制作流程去理解RDD的4大属性。
首先,咱们沿着纵向,也就是从上到下的方向,去观察上图中土豆工坊的制作工艺。
我们可以看到对于每一种食材形态来说流水线上都有多个实物与之对应比如“带泥土豆”是一种食材形态流水线上总共有3颗“脏兮兮”的土豆同属于这一形态。
如果把“带泥土豆”看成是RDD的话那么RDD的partitions属性囊括的正是麻袋里那一颗颗脏兮兮的土豆。同理流水线上所有洗净的土豆一同构成了“干净土豆”RDD的partitions属性。
我们再来看RDD的partitioner属性这个属性定义了把原始数据集切割成数据分片的切割规则。在土豆工坊的例子中“带泥土豆”RDD的切割规则是随机拿取也就是从麻袋中随机拿取一颗脏兮兮的土豆放到流水线上。后面的食材形态如“干净土豆”、“土豆片”和“即食薯片”则沿用了“带泥土豆”RDD的切割规则。换句话说后续的这些RDD分别继承了前一个RDD的partitioner属性。
这里面与众不同的是“分发的即食薯片”。显然“分发的即食薯片”是通过对“即食薯片”按照大、中、小号做分发得到的。也就是说对于“分发的即食薯片”来说它的partitioner属性重新定义了这个RDD数据分片的切割规则也就是把先前RDD的数据分片打散按照薯片尺寸重新构建数据分片。
由这个例子我们可以看出数据分片的分布是由RDD的partitioner决定的。因此RDD的partitions属性与它的partitioner属性是强相关的。
横看成岭侧成峰,很多事情换个视角看,相貌可能会完全不同。所以接下来,我们横向地,也就是沿着从左至右的方向,再来观察土豆工坊的制作工艺。
不难发现流水线上的每一种食材形态都是上一种食材形态在某种操作下进行转换得到的。比如“土豆片”依赖的食材形态是“干净土豆”这中间用于转换的操作是“切片”这个动作。回顾Word Count当中RDD之间的转换关系我们也会发现类似的现象。
在数据形态的转换过程中每个RDD都会通过dependencies属性来记录它所依赖的前一个、或是多个RDD简称“父RDD”。与此同时RDD使用compute属性来记录从父RDD到当前RDD的转换操作。
拿Word Count当中的wordRDD来举例它的父RDD是lineRDD因此它的dependencies属性记录的是lineRDD。从lineRDD到wordRDD的转换其所依赖的操作是flatMap因此wordRDD的compute属性记录的是flatMap这个转换函数。
总结下来薯片的加工流程与RDD的概念和4大属性是一一对应的
不同的食材形态如带泥土豆、土豆片、即食薯片等等对应的就是RDD概念
同一种食材形态在不同流水线上的具体实物,就是 RDD 的 partitions 属性;
食材按照什么规则被分配到哪条流水线,对应的就是 RDD 的 partitioner 属性;
每一种食材形态都会依赖上一种形态,这种依赖关系对应的是 RDD 中的 dependencies 属性;
不同环节的加工方法对应 RDD的 compute 属性。
在你理解了RDD的4大属性之后还需要进一步了解RDD的编程模型和延迟计算。编程模型指导我们如何进行代码实现而延迟计算是Spark分布式运行机制的基础。只有搞明白编程模型与延迟计算你才能流畅地在Spark之上做应用开发在实现业务逻辑的同时避免埋下性能隐患。
编程模型与延迟计算
你还记得我在上一讲的最后给你留的一道思考题吗map、filter、flatMap和reduceByKey这些算子有哪些共同点现在我们来揭晓答案
首先这4个算子都是作用Apply在RDD之上、用来做RDD之间的转换。比如flatMap作用在lineRDD之上把lineRDD转换为wordRDD。
其次这些算子本身是函数而且它们的参数也是函数。参数是函数、或者返回值是函数的函数我们把这类函数统称为“高阶函数”Higher-order Functions。换句话说这4个算子都是高阶函数。
关于高阶函数的作用与优劣势我们留到后面再去展开。这里我们先专注在RDD算子的第一个共性RDD转换。
RDD是Spark对于分布式数据集的抽象每一个RDD都代表着一种分布式数据形态。比如lineRDD它表示数据在集群中以行Line的形式存在而wordRDD则意味着数据的形态是单词分布在计算集群中。
理解了RDD那什么是RDD转换呢别着急我来以上次Word Count的实现代码为例来给你讲一下。以下是我们上次用的代码
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
回顾Word Count示例我们会发现Word Count的实现过程实际上就是不同RDD之间的一个转换过程。仔细观察我们会发现Word Count示例中一共有4次RDD的转换我来具体解释一下
起初我们通过调用textFile API生成lineRDD然后用flatMap算子把lineRDD转换为wordRDD-
接下来filter算子对wordRDD做过滤并把它转换为不带空串的cleanWordRDD-
然后为了后续的聚合计算map算子把cleanWordRDD又转换成元素为KeyValue对的kvRDD-
最终我们调用reduceByKey做分组聚合把kvRDD中的Value从1转换为单词计数。
这4步转换的过程如下图所示
我们刚刚说过RDD代表的是分布式数据形态因此RDD到RDD之间的转换本质上是数据形态上的转换Transformations
在RDD的编程模型中一共有两种算子Transformations类算子和Actions类算子。开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
在这样的编程模型下Spark在运行时的计算被划分为两个环节。
基于不同数据形态之间的转换构建计算流图DAGDirected Acyclic Graph
通过Actions类算子以回溯的方式去触发执行这个计算流图。
换句话说开发者调用的各类Transformations算子并不立即执行计算当且仅当开发者调用Actions算子时之前调用的转换算子才会付诸执行。在业内这样的计算模式有个专门的术语叫作“延迟计算”Lazy Evaluation
延迟计算很好地解释了本讲开头的问题为什么Word Count在执行的过程中只有最后一行代码会花费很长时间而前面的代码都是瞬间执行完毕的呢
这里的答案正是Spark的延迟计算。flatMap、filter、map这些算子仅用于构建计算流图因此当你在spark-shell中敲入这些代码时spark-shell会立即返回。只有在你敲入最后那行包含take的代码时Spark才会触发执行从头到尾的计算流程所以直观地看上去最后一行代码是最耗时的。
Spark程序的整个运行流程如下图所示
你可能会问“在RDD的开发框架下哪些算子属于Transformations算子哪些算子是Actions算子呢
我们都知道Spark有很多算子Spark官网提供了完整的RDD算子集合不过对于这些算子官网更多地是采用一种罗列的方式去呈现的没有进行分类看得人眼花缭乱、昏昏欲睡。因此我把常用的RDD算子进行了归类并整理到了下面的表格中供你随时查阅。
结合每个算子的分类、用途和适用场景这张表格可以帮你更快、更高效地选择合适的算子来实现业务逻辑。对于表格中不熟悉的算子比如aggregateByKey你可以结合官网的介绍与解释或是进一步查阅网上的相关资料有的放矢地去深入理解。重要的算子我们会在之后的课里详细解释。
重点回顾
今天这一讲我们重点讲解了RDD的编程模型与延迟计算并通过土豆工坊的类比介绍了什么是RDD。RDD是Spark对于分布式数据集的抽象它用于囊括所有内存中和磁盘中的分布式数据实体。对于RDD你要重点掌握它的4大属性这是我们后续学习的重要基础
partitions数据分片
partitioner分片切割规则
dependenciesRDD依赖
compute转换函数
深入理解RDD之后你需要熟悉RDD的编程模型。在RDD的编程模型中开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
而延迟计算指的是开发者调用的各类Transformations算子并不会立即执行计算当且仅当开发者调用Actions算子时之前调用的转换算子才会付诸执行。
每课一练
对于Word Count的计算流图与土豆工坊的流水线工艺尽管看上去毫不相关风马牛不相及不过你不妨花点时间想一想它们之间有哪些区别和联系
欢迎你把答案分享到评论区,我在评论区等你,也欢迎你把这一讲分享给更多的朋友和同事,我们下一讲见!

View File

@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 RDD常用算子RDD内部的数据转换
你好,我是吴磊。
在上一讲的最后我们用一张表格整理了Spark官网给出的RDD算子。想要在Spark之上快速实现业务逻辑理解并掌握这些算子无疑是至关重要的。
因此,在接下来的几讲,我将带你一起梳理这些常见算子的用法与用途。不同的算子,就像是厨房里的炒勺、铲子、刀具和各式各样的锅碗瓢盆,只有熟悉了这些“厨具”的操作方法,才能在客人点餐的时候迅速地做出一桌好菜。
今天这一讲我们先来学习同一个RDD内部的数据转换。掌握RDD常用算子是做好Spark应用开发的基础而数据转换类算子则是基础中的基础因此我们优先来学习这类RDD算子。
在这些算子中我们重点讲解的就是map、mapPartitions、flatMap、filter。这4个算子几乎囊括了日常开发中99%的数据转换场景剩下的mapPartitionsWithIndex我把它留给你作为课后作业去探索。
俗话说巧妇难为无米之炊要想玩转厨房里的厨具我们得先准备好米、面、油这些食材。学习RDD算子也是一样要想动手操作这些算子咱们得先有RDD才行。
所以接下来我们就一起来看看RDD是怎么创建的。
创建RDD
在Spark中创建RDD的典型方式有两种
通过SparkContext.parallelize在内部数据之上创建RDD
通过SparkContext.textFile等API从外部数据创建RDD。
这里的内部、外部是相对应用程序来说的。开发者在Spark应用中自定义的各类数据结构如数组、列表、映射等都属于“内部数据”而“外部数据”指代的是Spark系统之外的所有数据形式如本地文件系统或是分布式文件系统中的数据再比如来自其他大数据组件Hive、Hbase、RDBMS等的数据。
第一种创建方式的用法非常简单只需要用parallelize函数来封装内部数据即可比如下面的例子
import org.apache.spark.rdd.RDD
val words: Array[String] = Array("Spark", "is", "cool")
val rdd: RDD[String] = sc.parallelize(words)
你可以在spark-shell中敲入上述代码来直观地感受parallelize创建RDD的过程。通常来说在Spark应用内定义体量超大的数据集其实都是不太合适的因为数据集完全由Driver端创建且创建完成后还要在全网范围内跨节点、跨进程地分发到其他Executors所以往往会带来性能问题。因此parallelize API的典型用法是在“小数据”之上创建RDD。
要想在真正的“大数据”之上创建RDD我们还得依赖第二种创建方式也就是通过SparkContext.textFile等API从外部数据创建RDD。由于textFile API比较简单而且它在日常的开发中出现频率比较高因此我们使用textFile API来创建RDD。在后续对各类RDD算子讲解的过程中我们都会使用textFile API从文件系统创建RDD。
为了保持讲解的连贯性我们还是使用第一讲中的源文件wikiOfSpark.txt来创建RDD代码实现如下所示
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
好啦创建好了RDD我们就有了可以下锅的食材。接下来咱们就要正式地走进厨房把铲子和炒勺挥起来啦。
RDD内的数据转换
首先我们先来认识一下map算子。毫不夸张地说在所有的RDD算子中map“出场”的概率是最高的。因此我们必须要掌握map的用法与注意事项。
map以元素为粒度的数据转换
我们先来说说map算子的用法给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带有明确签名的带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
这种照本宣科的介绍听上去难免会让你有点懵别着急接下来我们用些小例子来更加直观地展示map的用法。
在[第一讲]的Word Count示例中我们使用如下代码把包含单词的RDD转换成元素为KeyValue对的RDD后者统称为Paired RDD。
// 把普通RDD转换为Paired RDD
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
在上面的代码实现中传递给map算子的形参word => word1就是我们上面说的映射函数f。只不过这里f是以匿名函数的方式进行定义的其中左侧的word表示匿名函数f的输入形参而右侧的word1则代表函数f的输出结果。
如果我们把匿名函数变成带名函数的话可能你会看的更清楚一些。这里我用一段代码重新定义了带名函数f。
// 把RDD元素转换为KeyValue的形式
// 定义映射函数f
def f(word: String): (String, Int) = {
return (word, 1)
}
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
可以看到我们使用Scala的def语法明确定义了带名映射函数f它的计算逻辑与刚刚的匿名函数是一致的。在做RDD数据转换的时候我们只需把函数f传递给map算子即可。不管f是匿名函数还是带名函数map算子的转换逻辑都是一样的你不妨把以上两种实现方式分别敲入到spark-shell去验证执行结果的一致性。
到这里为止我们就掌握了map算子的基本用法。现在你就可以定义任意复杂的映射函数f然后在RDD之上通过调用map(f)去翻着花样地做各种各样的数据转换。
比如通过定义如下的映射函数f我们就可以改写Word Count的计数逻辑也就是把“Spark”这个单词的统计计数权重提高一倍
// 把RDD元素转换为KeyValue的形式
// 定义映射函数f
def f(word: String): (String, Int) = {
if (word.equals("Spark")) { return (word, 2) }
return (word, 1)
}
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
尽管map算子足够灵活允许开发者自由定义转换逻辑。不过就像我们刚刚说的map(f)是以元素为粒度对RDD做数据转换的在某些计算场景下这个特点会严重影响执行效率。为什么这么说呢我们来看一个具体的例子。
比方说我们把Word Count的计数需求从原来的对单词计数改为对单词的哈希值计数在这种情况下我们的代码实现需要做哪些改动呢我来示范一下
// 把普通RDD转换为Paired RDD
import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map{ word =>
// 获取MD5对象实例
val md5 = MessageDigest.getInstance("MD5")
// 使用MD5计算哈希值
val hash = md5.digest(word.getBytes).mkString
// 返回哈希值与数字1的Pair
(hash, 1)
}
由于map(f)是以元素为单元做转换的那么对于RDD中的每一条数据记录我们都需要实例化一个MessageDigest对象来计算这个元素的哈希值。
在工业级生产系统中一个RDD动辄包含上百万甚至是上亿级别的数据记录如果处理每条记录都需要事先创建MessageDigest那么实例化对象的开销就会聚沙成塔不知不觉地成为影响执行效率的罪魁祸首。
那么问题来了有没有什么办法能够让Spark在更粗的数据粒度上去处理数据呢还真有mapPartitions和mapPartitionsWithIndex这对“孪生兄弟”就是用来解决类似的问题。相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引因此接下来我们把重点放在mapPartitions上面。
mapPartitions以数据分区为粒度的数据转换
按照介绍算子的惯例我们还是先来说说mapPartitions的用法。mapPartitions顾名思义就是以数据分区为粒度使用映射函数f对RDD进行数据转换。对于上述单词哈希值计数的例子我们结合后面的代码来看看如何使用mapPartitions来改善执行性能
// 把普通RDD转换为Paired RDD
import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.mapPartitions( partition => {
// 注意这里是以数据分区为粒度获取MD5对象实例
val md5 = MessageDigest.getInstance("MD5")
val newPartition = partition.map( word => {
// 在处理每一条数据记录的时候可以复用同一个Partition内的MD5对象
(md5.digest(word.getBytes()).mkString,1)
})
newPartition
})
可以看到在上面的改进代码中mapPartitions以数据分区匿名函数的形参partition为粒度对RDD进行数据转换。具体的数据处理逻辑则由代表数据分区的形参partition进一步调用map(f)来完成。你可能会说“partition. map(f)仍然是以元素为粒度做映射呀!这和前一个版本的实现,有什么本质上的区别呢?”
仔细观察你就会发现相比前一个版本我们把实例化MD5对象的语句挪到了map算子之外。如此一来以数据分区为单位实例化对象的操作只需要执行一次而同一个数据分区中所有的数据记录都可以共享该MD5对象从而完成单词到哈希值的转换。
通过下图的直观对比你会发现以数据分区为单位mapPartitions只需实例化一次MD5对象而map算子却需要实例化多次具体的次数则由分区内数据记录的数量来决定。
对于一个有着上百万条记录的RDD来说其数据分区的划分往往是在百这个量级因此相比map算子mapPartitions可以显著降低对象实例化的计算开销这对于Spark作业端到端的执行性能来说无疑是非常友好的。
实际上。除了计算哈希值以外对于数据记录来说凡是可以共享的操作都可以用mapPartitions算子进行优化。这样的共享操作还有很多比如创建用于连接远端数据库的Connections对象或是用于连接Amazon S3的文件系统句柄再比如用于在线推理的机器学习模型等等不一而足。你不妨结合实际工作场景把你遇到的共享操作整理到留言区期待你的分享。
相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引这个数据分区索引可以为我们获取分区编号当你的业务逻辑中需要使用到分区编号的时候不妨考虑使用这个算子来实现代码。除了这个额外的分区索引以外mapPartitionsWithIndex在其他方面与mapPartitions是完全一样的。
介绍完map与mapPartitions算子之后接下来我们趁热打铁再来看一个与这两者功能类似的算子flatMap。
flatMap从元素到集合、再从集合到元素
flatMap其实和map与mapPartitions算子类似在功能上与map和mapPartitions一样flatMap也是用来做数据映射的在实现上对于给定映射函数fflatMap(f)以元素为粒度对RDD进行数据转换。
不过与前两者相比flatMap的映射函数f有着显著的不同。对于map和mapPartitions来说其映射函数f的类型都是元素 => 元素即元素到元素。而flatMap映射函数f的类型元素 => 集合即元素到集合如数组、列表等。因此flatMap的映射过程在逻辑上分为两步
以元素为单位,创建集合;
去掉集合“外包装”,提取集合元素。
这么说比较抽象我们还是来举例说明。假设我们再次改变Word Count的计算逻辑由原来统计单词的计数改为统计相邻单词共现的次数如下图所示
对于这样的计算逻辑我们该如何使用flatMap进行实现呢这里我们先给出代码实现然后再分阶段地分析flatMap的映射过程
// 读取文件内容
val lineRDD: RDD[String] = _ // 请参考第一讲获取完整代码
// 以行为单位提取相邻单词
val wordPairRDD: RDD[String] = lineRDD.flatMap( line => {
// 将行转换为单词数组
val words: Array[String] = line.split(" ")
// 将单个单词数组,转换为相邻单词数组
for (i <- 0 until words.length - 1) yield words(i) + "-" + words(i+1)
})
在上面的代码中我们采用匿名函数的形式来提供映射函数f。这里f的形参是String类型的line也就是源文件中的一行文本而f的返回类型是Array[String]也就是String类型的数组。在映射函数f的函数体中我们先用split语句把line转化为单词数组然后再用for循环结合yield语句依次把单个的单词转化为相邻单词词对。
注意for循环返回的依然是数组也即类型为Array[String]的词对数组。由此可见函数f的类型是String => Array[String]也就是刚刚说的第一步从元素到集合。但如果我们去观察转换前后的两个RDD也就是lineRDD和wordPairRDD会发现它们的类型都是RDD[String]换句话说它们的元素类型都是String。
回顾map与mapPartitions这两个算子我们会发现转换前后RDD的元素类型与映射函数f的类型是一致的。但在flatMap这里却出现了RDD元素类型与函数类型不一致的情况。这是怎么回事呢其实呢这正是flatMap的“奥妙”所在为了让你直观地理解flatMap的映射过程我画了一张示意图如下所示
不难发现映射函数f的计算过程对应着图中的步骤1与步骤2每行文本都被转化为包含相邻词对的数组。紧接着flatMap去掉每个数组的“外包装”提取出数组中类型为String的词对元素然后以词对为单位构建新的数据分区如图中步骤3所示。这就是flatMap映射过程的第二步去掉集合“外包装”提取集合元素。
得到包含词对元素的wordPairRDD之后我们就可以沿用Word Count的后续逻辑去计算相邻词汇的共现次数。你不妨结合文稿中的代码与第一讲中Word Count的代码去实现完整版的“相邻词汇计数统计”。
filter过滤RDD
在今天的最后我们再来学习一下与map一样常用的算子filter。filter顾名思义这个算子的作用是对RDD进行过滤。就像是map算子依赖其映射函数一样filter算子也需要借助一个判定函数f才能实现对RDD的过滤转换。
所谓判定函数它指的是类型为RDD元素类型 => Boolean的函数。可以看到判定函数f的形参类型必须与RDD的元素类型保持一致而f的返回结果只能是True或者False。在任何一个RDD之上调用filter(f)其作用是保留RDD中满足f也就是f返回True的数据元素而过滤掉不满足f也就是f返回False的数据元素。
老规矩我们还是结合示例来讲解filter算子与判定函数f。
在上面flatMap例子的最后我们得到了元素为相邻词汇对的wordPairRDD它包含的是像“Spark-is”、“is-cool”这样的字符串。为了仅保留有意义的词对元素我们希望结合标点符号列表对wordPairRDD进行过滤。例如我们希望过滤掉像“Spark-&”、“|-data”这样的词对。
掌握了filter算子的用法之后要实现这样的过滤逻辑我相信你很快就能写出如下的代码实现
// 定义特殊字符列表
val list: List[String] = List("&", "|", "#", "^", "@")
// 定义判定函数f
def f(s: String): Boolean = {
val words: Array[String] = s.split("-")
val b1: Boolean = list.contains(words(0))
val b2: Boolean = list.contains(words(1))
return !b1 && !b2 // 返回不在特殊字符列表中的词汇对
}
// 使用filter(f)对RDD进行过滤
val cleanedPairRDD: RDD[String] = wordPairRDD.filter(f)
掌握了filter算子的用法之后你就可以定义任意复杂的判定函数f然后在RDD之上通过调用filter(f)去变着花样地做数据过滤,从而满足不同的业务需求。
重点回顾
好啦到此为止关于RDD内数据转换的几个算子我们就讲完了我们一起来做个总结。今天这一讲你需要掌握map、mapPartitions、flatMap和filter这4个算子的作用和具体用法。
首先我们讲了map算子的用法它允许开发者自由地对RDD做各式各样的数据转换给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
为了提升数据转换的效率Spark提供了以数据分区为粒度的mapPartitions算子。mapPartitions的形参是代表数据分区的partition它通过在partition之上再次调用map(f)来完成数据的转换。相比mapmapPartitions的优势是以数据分区为粒度初始化共享对象这些共享对象在我们日常的开发中很常见比如数据库连接对象、S3文件句柄、机器学习模型等等。
紧接着我们介绍了flatMap算子。flatMap的映射函数f比较特殊它的函数类型是元素 => 集合这里集合指的是像数组、列表这样的数据结构。因此flatMap的映射过程在逻辑上分为两步这一点需要你特别注意
以元素为单位,创建集合;
去掉集合“外包装”,提取集合元素。
最后我们学习了filter算子filter算子的用法与map很像它需要借助判定函数f来完成对RDD的数据过滤。判定函数的类型必须是RDD元素类型 => Boolean也就是形参类型必须与RDD的元素类型保持一致返回结果类型则必须是布尔值。RDD中的元素是否能够得以保留取决于判定函数f的返回值是True还是False。
虽然今天我们只学了4个算子但这4个算子在日常开发中的出现频率非常之高。掌握了这几个简单的RDD算子你几乎可以应对RDD中90%的数据转换场景。希望你对这几个算子多多加以练习,从而在日常的开发工作中学以致用。
每课一练
讲完了正课我来给你留3个思考题
请你结合官网的介绍自学mapPartitionsWithIndex算子。请你说一说在哪些场景下可能会用到这个算子
对于我们今天学过的4个算子再加上没有详细解释的mapPartitionsWithIndex你能说说它们之间有哪些共性或是共同点吗
你能说一说在日常的工作中还遇到过哪些可以在mapPartitions中初始化的共享对象呢
欢迎你在评论区回答这些练习题。你也可以把这一讲分享给更多的朋友或者同事,和他们一起讨论讨论,交流是学习的催化剂。我在评论区等你。

View File

@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 进程模型与分布式部署:分布式计算是怎么回事?
你好,我是吴磊。
在[第2讲]的最后我们留了一道思考题。Word Count的计算流图与土豆工坊的流水线工艺二者之间有哪些区别和联系如果你有点记不清了可以看下后面的图回忆一下。
我们先来说区别。首先Word Count计算流图是一种抽象的流程图而土豆工坊的流水线是可操作、可运行而又具体的执行步骤。然后计算流图中的每一个元素如lineRDD、wordRDD都是“虚”的数据集抽象而流水线上各个环节不同形态的食材比如一颗颗脏兮兮的土豆都是“实实在在”的实物。
厘清了二者之间的区别之后,它们之间的联系自然也就显而易见了。如果把计算流图看作是“设计图纸”,那么流水线工艺其实就是“施工过程”。前者是设计层面、高屋建瓴的指导意见,而后者是执行层面、按部就班的实施过程。前者是后者的基石,而后者是前者的具化。
你可能会好奇:“我们为什么非要弄清这二者之间的区别和联系呢?”原因其实很简单,分布式计算的精髓,在于如何把抽象的计算流图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。
今天这一讲我们就来聊一聊Spark是如何实现分布式计算的。分布式计算的实现离不开两个关键要素一个是进程模型另一个是分布式的环境部署。接下来我们先去探讨Spark的进程模型然后再来介绍Spark都有哪些分布式部署方式。
进程模型
在Spark的应用开发中任何一个应用程序的入口都是带有SparkSession的main函数。SparkSession包罗万象它在提供Spark运行时上下文的同时如调度系统、存储系统、内存管理、RPC通信也可以为开发者提供创建、转换、计算分布式数据集如RDD的开发API。
不过在Spark分布式计算环境中有且仅有一个JVM进程运行这样的main函数这个特殊的JVM进程在Spark中有个专门的术语叫作“Driver”。
Driver最核心的作用在于解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的执行进程交付运行。换句话说Driver的角色是拆解任务、派活儿而真正干活儿的“苦力”是执行进程。在Spark的分布式环境中这样的执行进程可以有一个或是多个它们也有专门的术语叫作“Executor”。
我把Driver和Executor的关系画成了一张图你可以看看
分布式计算的核心是任务调度而分布式任务的调度与执行仰仗的是Driver与Executors之间的通力合作。在后续的课程中我们会深入讲解Driver如何与众多Executors协作完成任务调度不过在此之前咱们先要厘清Driver与Executors的关系从而为后续的课程打下坚实的基础。
Driver与Executors包工头与施工工人
简单来看Driver与Executors的关系就像是工地上包工头与施工工人们之间的关系。包工头负责“揽活儿”拿到设计图纸之后负责拆解任务把二维平面图细化成夯土、打地基、砌墙、浇筑钢筋混凝土等任务然后再把任务派发给手下的工人。工人们认领到任务之后相对独立地去完成各自的任务仅在必要的时候进行沟通与协调。
其实不同的建筑任务之间往往是存在依赖关系的比如砌墙一定是在地基打成之后才能施工同理浇筑钢筋混凝土也一定要等到砖墙砌成之后才能进行。因此Driver这个“包工头”的重要职责之一就是合理有序地拆解并安排建筑任务。
再者为了保证施工进度Driver除了分发任务之外还需要定期与每个Executor进行沟通及时获取他们的工作进展从而协调整体的执行进度。
一个篱笆三个桩一个好汉三个帮。要履行上述一系列的职责Driver自然需要一些给力的帮手才行。在Spark的Driver进程中DAGScheduler、TaskScheduler和SchedulerBackend这三个对象通力合作依次完成分布式任务调度的3个核心步骤也就是
根据用户代码构建计算流图;-
根据计算流图拆解出分布式任务;-
将分布式任务分发到Executors中去。
接收到任务之后Executors调用内部线程池结合事先分配好的数据分片并发地执行任务代码。对于一个完整的RDD每个Executors负责处理这个RDD的一个数据分片子集。这就好比是对于工地上所有的砖头甲、乙、丙三个工人分别认领其中的三分之一然后拿来分别构筑东、西、北三面高墙。
好啦到目前为止关于Driver和Executors的概念他们各自的职责以及相互之间的关系我们有了最基本的了解。尽管对于一些关键对象如上述DAGScheduler、TaskScheduler我们还有待深入但这并不影响咱们居高临下地去理解Spark进程模型。
不过你可能会说“一说到模型就总觉得抽象能不能结合示例来具体说明呢”接下来我们还是沿用前两讲展示的Word Count示例一起去探究spark-shell在幕后是如何运行的。
spark-shell执行过程解析
在第1讲我们在本机搭建了Spark本地运行环境并通过在终端敲入spark-shell进入交互式REPL。与很多其他系统命令一样spark-shell有很多命令行参数其中最为重要的有两类一类是用于指定部署模式的master另一类则用于指定集群的计算资源容量。
不带任何参数的spark-shell命令实际上等同于下方这个命令
spark-shell --master local[*]
这行代码的含义有两层。第一层含义是部署模式其中local关键字表示部署模式为Local也就是本地部署第二层含义是部署规模也就是方括号里面的数字它表示的是在本地部署中需要启动多少个Executors星号则意味着这个数量与机器中可用CPU的个数相一致。
也就是说假设你的笔记本电脑有4个CPU那么当你在命令行敲入spark-shell的时候Spark会在后台启动1个Driver进程和3个Executors进程。
那么问题来了当我们把Word Count的示例代码依次敲入到spark-shell中Driver进程和3个Executors进程之间是如何通力合作来执行分布式任务的呢
为了帮你理解这个过程,我特意画了一张图,你可以先看一下整体的执行过程:
首先Driver通过take这个Action算子来触发执行先前构建好的计算流图。沿着计算流图的执行方向也就是图中从上到下的方向Driver以Shuffle为边界创建、分发分布式任务。
Shuffle的本意是扑克牌中的“洗牌”在大数据领域的引申义表示的是集群范围内跨进程、跨节点的数据交换。我们在专栏后续的内容中会对Shuffle做专门的讲解这里我们不妨先用Word Count的例子来简单地对Shuffle进行理解。
在reduceByKey算子之前同一个单词比如“spark”可能散落在不用的Executors进程比如图中的Executor-0、Executor-1和Executor-2。换句话说这些Executors处理的数据分片中都包含单词“spark”。
那么要完成对“spark”的计数我们需要把所有“spark”分发到同一个Executor进程才能完成计算。而这个把原本散落在不同Executors的单词分发到同一个Executor的过程就是Shuffle。
大概理解了Shuffle后我们回过头接着说Driver是怎么创建分布式任务的。对于reduceByKey之前的所有操作也就是textFile、flatMap、filter、map等Driver会把它们“捏合”成一份任务然后一次性地把这份任务打包、分发给每一个Executors。
三个Executors接收到任务之后先是对任务进行解析把任务拆解成textFile、flatMap、filter、map这4个步骤然后分别对自己负责的数据分片进行处理。
为了方便说明我们不妨假设并行度为3也就是原始数据文件wikiOfSpark.txt被切割成了3份这样每个Executors刚好处理其中的一份。数据处理完毕之后分片内容就从原来的RDD[String]转换成了包含键值对的RDD[(String, Int)]其中每个单词的计数都置位1。此时Executors会及时地向Driver汇报自己的工作进展从而方便Driver来统一协调大家下一步的工作。
这个时候要继续进行后面的聚合计算也就是计数操作就必须进行刚刚说的Shuffle操作。在不同Executors完成单词的数据交换之后Driver继续创建并分发下一个阶段的任务也就是按照单词做分组计数。
数据交换之后所有相同的单词都分发到了相同的Executors上去这个时候各个Executors拿到reduceByKey的任务只需要各自独立地去完成统计计数即可。完成计数之后Executors会把最终的计算结果统一返回给Driver。
这样一来spark-shell便完成了Word Count用户代码的计算过程。经过了刚才的分析对于Spark进程模型、Driver与Executors之间的关联与联系想必你就有了更清晰的理解和把握。
不过到目前为止对于Word Count示例和spark-shell的讲解我们一直是在本地部署的环境中做展示。我们知道Spark真正的威力其实在于分布式集群中的并行计算。只有充分利用集群中每个节点的计算资源才能充分发挥出Spark的性能优势。因此我们很有必要去学习并了解Spark的分布式部署。
分布式环境部署
Spark支持多种分布式部署模式如Standalone、YARN、Mesos、Kubernetes。其中Standalone是Spark内置的资源调度器而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。
由于后三者提供独立而又完备的资源调度能力对于这些框架来说Spark仅仅是其支持的众多计算引擎中的一种。Spark在这些独立框架上的分布式部署步骤较少流程比较简单我们开发者只需下载并解压Spark安装包然后适当调整Spark配置文件、以及修改环境变量就行了。
因此对于YARN、Mesos、Kubernetes这三种部署模式我们不做详细展开我把它给你留作课后作业进行探索。今天这一讲我们仅专注于Spark在Standalone模式下的分布式部署。
为了示意Standalone模式的部署过程我这边在AWS环境中创建并启动了3台EC2计算节点操作系统为Linux/CentOS。
需要指出的是Spark分布式计算环境的部署对于节点类型与操作系统本身是没有要求和限制的但是在实际的部署中请你尽量保持每台计算节点的操作系统是一致的从而避免不必要的麻烦。
接下来我就带你手把手地去完成Standalone模式的分布式部署。
Standalone在资源调度层面采用了一主多从的主从架构把计算节点的角色分为Master和Worker。其中Master有且只有一个而Worker可以有一到多个。所有Worker节点周期性地向Master汇报本节点可用资源状态Master负责汇总、变更、管理集群中的可用资源并对Spark应用程序中Driver的资源请求作出响应。
为了方便描述我们把3台EC2的hostname分别设置为node0、node1、node2并把node0选做Master节点而把node1、node2选做Worker节点。
首先为了实现3台机器之间的无缝通信我们先来在3台节点之间配置无密码的SSH环境
接下来我们在所有节点上准备Java环境并安装Spark其中步骤2的“sudo wget”你可以参考这里的链接操作命令如下表所示
在所有节点之上完成Spark的安装之后我们就可以依次启动Master和Worker节点了如下表所示
集群启动之后我们可以使用Spark自带的小例子来验证Standalone分布式部署是否成功。首先打开Master或是Worker的命令行终端然后敲入下面这个命令
MASTER=spark://node0:7077 $SPARK_HOME/bin/run-example org.apache.spark.examples.SparkPi
如果程序能够成功计算Pi值也就是3.14如下图所示那么说明咱们的Spark分布式计算集群已然就绪。你可以对照文稿里的截图验证下你的环境是否也成功了。
重点回顾
今天这一讲我们提到分布式计算的精髓在于如何把抽象的计算流图转化为实实在在的分布式计算任务然后以并行计算的方式交付执行。而要想透彻理解分布式计算你就需要掌握Spark进程模型。
进程模型的核心是Driver和Executors我们需要重点理解它们之间的协作关系。任何一个Spark应用程序的入口都是带有SparkSession的main函数而在Spark的分布式计算环境中运行这样main函数的JVM进程有且仅有一个它被称为 “Driver”。
Driver最核心的作用在于解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的Executors交付执行。接收到任务之后Executors调用内部线程池结合事先分配好的数据分片并发地执行任务代码。
对于一个完整的RDD每个Executors负责处理这个RDD的一个数据分片子集。每当任务执行完毕Executors都会及时地与Driver进行通信、汇报任务状态。Driver在获取到Executors的执行进度之后结合计算流图的任务拆解依次有序地将下一阶段的任务再次分发给Executors付诸执行直至整个计算流图执行完毕。
之后我们介绍了Spark支持的分布式部署模式主要有Standalone、YARN、Mesos、Kubernetes。其中Standalone是Spark内置的资源调度器而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。在这些部署模式中你需要重点掌握Standalone环境部署的操作步骤。
每课一练
好,在这一讲的最后,我给你留两道作业,帮助你巩固今日所学。
与take算子类似collect算子用于收集计算结果结合Spark进程模型你能说一说相比collect算子相比take算子来说都有哪些隐患吗
如果你的生产环境中使用了YARN、Mesos或是Kubernetes你不妨说一说要完成Spark在这些独立框架下的分布式部署都需要哪些必备的步骤
今天这一讲就到这里了,如果你在部署过程中遇到的什么问题,欢迎你在评论区提问。如果你觉得这一讲帮助到了你,也欢迎你分享给更多的朋友和同事,我们下一讲再见。

View File

@ -0,0 +1,201 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 调度系统:如何把握分布式计算的精髓?
你好,我是吴磊。
在上一讲我们通过“包工头与施工工人”的例子初步认识了Spark进程模型中的Driver和Executors、以及它们之间的交互关系。Driver负责解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的Executors交付运行。
不过你可能会好奇“对于给定的用户代码和相应的计算流图Driver是怎么把计算图拆解为分布式任务又是按照什么规则分发给Executors的呢还有Executors具体又是如何执行分布式任务的呢
我们之前一再强调,分布式计算的精髓,在于如何把抽象的计算图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。深入理解分布式计算,是我们做好大数据开发的关键和前提,它能有效避免我们掉入“单机思维”的陷阱,同时也能为性能导向的开发奠定坚实基础。
而上面的这一系列问题恰恰是我们吃透分布式计算的关键所在。因此今天这一讲我们就顺着这些问题一起去深入探究Spark调度系统进而弄清楚分布式计算的来龙去脉。
角色划分与斯巴克建筑集团
在上一讲我们通过“包工头与施工工人”的类比、以及Word Count的示例其实已经大致厘清了Spark分布式任务调度的核心环节与关键步骤。今天这一讲的核心任务就是带你去深入其中的每一个环节做到“既见森林、也见树木”。这里咱们不妨先把这些环节和涉及的组件梳理出来从而让你在整体上有一个清晰的把握。
不难发现表中的步骤与组件众多要是照本宣科地去讲调度系统先别说你可能看着看着就开始犯困了就连我自己也可能写着写着就睡着了。因此咱们不妨把这些环节与组件融入到一个故事中去让你像读小说一样在捧腹之余弄懂Spark调度系统。
话说很久以前美国有一家名扬海内外的建筑集团名为“斯巴克Spark”。这家建筑集团规模庞大设有一个总公司Driver和多个分公司Executors。斯巴克公司的主要服务对象是建筑设计师开发者建筑设计师负责提供设计图纸用户代码、计算图而斯巴克公司的主营业务是将图纸落地、建造起一栋栋高楼大厦。
要完成主营业务集团公司需要招聘能够看懂图纸、并将其转化为建筑项目的架构师因此斯巴克公司挖角了行业知名架构师“戴格”DAGScheduler。集团公司给戴格安排的职位是总公司的一把手同时要求两位创始元老“塔斯克”和“拜肯德”全力配合戴格的工作。
听到这里,你肯定会问“塔斯克”和“拜肯德”是谁呢?
塔斯克TaskScheduler一毕业就加入了斯巴克公司现任总公司施工经理成功指挥完成了多个大大小小的工程项目业绩非常突出深得公司赏识。拜肯德SchedulerBackend和塔斯克在上大学的时候就是上下铺关系好得穿一条裤子现任总公司人力资源总监负责与分公司协调、安排人力资源。从公司的安排来看三位主管的分工还是比较明确的。
之所以说塔斯克TaskScheduler和拜肯德SchedulerBackend是公司元老原因在于在SparkContext / SparkSession的初始化中TaskScheduler和SchedulerBackend是最早、且同时被创建的调度系统组件。这二者的关系非常微妙SchedulerBackend在构造方法中引用TaskScheduler而TaskScheduler在初始化时会引用SchedulerBackend。
值得一提的是SchedulerBackend组件的实例化取决于开发者指定的Spark MasterURL也就是我们使用spark-shell或是spark-submit时指定的master 参数如“master spark://ip:host”就代表Standalone 部署模式master yarn”就代表YARN 模式等等。
不难发现SchedulerBackend 与资源管理器Standalone、YARN、Mesos等强绑定是资源管理器在 Spark 中的代理。其实硬件资源与人力资源一样都是“干活儿的”。所以如果我们用集团公司的人力资源来类比Spark集群的硬件资源那么“拜肯德”就是名副其实的人力资源总监。
从全局视角来看DAGScheduler是任务调度的发起者DAGScheduler以TaskSet为粒度向TaskScheduler提交任务调度请求。TaskScheduler在初始化的过程中会创建任务调度队列任务调度队列用于缓存 DAGScheduler提交的TaskSets。TaskScheduler结合SchedulerBackend提供的 WorkerOffer按照预先设置的调度策略依次对队列中的任务进行调度。
简而言之DAGScheduler手里有“活儿”SchedulerBackend手里有“人力”TaskScheduler的核心职能就是把合适的“活儿”派发到合适的“人”的手里。由此可见TaskScheduler承担的是承上启下、上通下达的关键角色这也正是我们将“塔斯克”视为斯巴克建筑公司元老之一的重要原因。
那么,了解了这三个主管的角色职责,我们接下来就来详细说说,他们是怎么各自完成自己的工作的。
总架戴格DAGScheduler
回到我们的故事里,戴格在两位元老的协助下,工作开展得还算顺利,然而,冰层之下,暗流涌动,作为一名空降的领导,戴老板还需亲自“露两手”,才能赢得平级的认可与信任。
作为集团公司的“总架”总架构师戴格的核心职责是把计算图DAG拆分为执行阶段StagesStages指的是不同的运行阶段同时还要负责把Stages转化为任务集合TaskSets也就是把“建筑图纸”转化成可执行、可操作的“建筑项目”。
用一句话来概括从 DAG 到 Stages 的拆分过程,那就是:以 Actions 算子为起点,从后向前回溯 DAG以 Shuffle 操作为边界去划分 Stages。
在[第2讲]介绍编程模型的时候我们以Word Count为例提到Spark作业的运行分为两个环节第一个是以惰性的方式构建计算图第二个则是通过Actions算子触发作业的从头计算
对于图中的第二个环节Spark在实际运行的过程中会把它再细化为两个步骤。第一个步骤就是以Shuffle为边界从后向前以递归的方式把逻辑上的计算图DAG转化成一个又一个Stages。
我们还是以Word Count为例Spark以take算子为起点依次把DAG中的RDD划入到第一个Stage直到遇到reduceByKey算子。由于reduceByKey算子会引入Shuffle因此第一个Stage创建完毕且只包含wordCounts这一个RDD。接下来Spark继续向前回溯由于未曾碰到会引入Shuffle的算子因此它把“沿途”所有的RDD都划入了第二个Stage。
在Stages创建完毕之后就到了触发计算的第二个步骤Spark从后向前以递归的方式依次提请执行所有的Stages。
具体来说在Word Count的例子中DAGScheduler最先提请执行的是Stage1。在提交的时候DAGScheduler发现Stage1依赖的父Stage也就是Stage0还没有执行过那么这个时候它会把Stage1的提交动作压栈转而去提请执行Stage0。当Stage0执行完毕的时候DAGScheduler通过出栈的动作再次提请执行Stage 1。
对于提请执行的每一个StageDAGScheduler根据Stage内RDD的partitions属性创建分布式任务集合TaskSet。TaskSet包含一个又一个分布式任务TaskRDD有多少数据分区TaskSet就包含多少个Task。换句话说Task与RDD的分区是一一对应的。
你可能会问“Task代表的是分布式任务不过它具体是什么呢”要更好地认识Task我们不妨来看看它的关键属性。
在上表中stageId、stageAttemptId标记了Task与执行阶段Stage的所属关系taskBinary则封装了隶属于这个执行阶段的用户代码partition就是我们刚刚说的RDD数据分区locs属性以字符串的形式记录了该任务倾向的计算节点或是Executor ID。
不难发现taskBinary、partition和locs这三个属性一起描述了这样一件事情Task应该在哪里locs为谁partition执行什么任务taskBinary
到这里我们讲完了戴格的职责让我们来一起简单汇总一下戴格指代的是DAGSchedulerDAGScheduler的主要职责有三个
根据用户代码构建DAG
以Shuffle为边界切割Stages
基于Stages创建TaskSets并将TaskSets提交给TaskScheduler请求调度。
现在,戴格不辱使命,完成了“建筑图纸”到“建筑项目”的转化,接下来,他需要把这些“活儿”下派给塔斯克,由塔斯克进一步完成任务的委派。
不过,对于塔斯克来说,要想把这些“活儿”委派出去,他得先摸清楚集团内有多少“适龄劳动力”才行。要做到这一点,他必须仰仗死党:拜肯德的帮忙。
拜肯德SchedulerBackend
作为集团公司的人力资源总监拜肯德的核心职责就是实时汇总并掌握全公司的人力资源状况。前面我们讲了全公司的人力资源对应的就是Spark的计算资源。对于集群中可用的计算资源SchedulerBackend用一个叫做ExecutorDataMap的数据结构来记录每一个计算节点中Executors的资源状态。
这里的ExecutorDataMap是一种HashMap它的Key是标记 Executor 的字符串Value是一种叫做ExecutorData的数据结构。ExecutorData用于封装Executor的资源状态如RPC地址、主机地址、可用CPU核数和满配CPU核数等等它相当于是对Executor做的“资源画像”。
有了ExecutorDataMap这本“人力资源小册子”对内SchedulerBackend可以就Executor做“资源画像”对外SchedulerBackend以WorkerOffer为粒度提供计算资源。其中WorkerOffer封装了Executor ID、主机地址和CPU核数它用来表示一份可用于调度任务的空闲资源。
显然基于Executor资源画像SchedulerBackend可以同时提供多个WorkerOffer用于分布式任务调度。WorkerOffer这个名字起得很传神Offer的字面意思是公司给你提供的工作机会到了Spark调度系统的上下文它就变成了使用硬件资源的机会。
你可能会好奇坐镇总公司的拜肯德对于整个集团的人力资源他是怎么做到足不出户就如数家珍的一个篱笆三个桩一个好汉三个帮。仅凭拜肯德一己之力自然是力不从心幕后功臣实际上是驻扎在分公司的一众小弟们ExecutorBackend。
SchedulerBackend与集群内所有Executors中的ExecutorBackend保持周期性通信双方通过LaunchedExecutor、RemoveExecutor、StatusUpdate等消息来互通有无、变更可用计算资源。拜肯德正是通过这些小弟发送的“信件”来不停地更新自己手中的那本小册子从而对集团人力资源了如指掌。
塔斯克TaskScheduler
一把手戴格有“活儿”三把手拜肯德出“人力”接下来终于轮到牵线搭桥的塔斯克出马了。作为施工经理塔斯克的核心职责是给定拜肯德提供的“人力”遴选出最合适的“活儿”并派发出去。而这个遴选的过程就是任务调度的核心所在如下图步骤3所示
那么问题来了对于SchedulerBackend提供的一个个WorkerOfferTaskScheduler是依据什么规则来挑选Tasks的呢
用一句话来回答对于给定的WorkerOfferTaskScheduler是按照任务的本地倾向性来遴选出TaskSet中适合调度的Tasks。这是什么意思呢听上去比较抽象我们还是从DAGScheduler在Stage内创建任务集TaskSet说起。
我们刚刚说过Task与RDD的partitions是一一对应的在创建Task的过程中DAGScheduler会根据数据分区的物理地址来为Task设置locs属性。locs属性记录了数据分区所在的计算节点、甚至是Executor进程ID。
举例来说当我们调用textFile API从HDFS文件系统中读取源文件时Spark会根据HDFS NameNode当中记录的元数据获取数据分区的存储地址例如node0:/rootPath/partition0-replica0node1:/rootPath/partition0-replica1和node2:/rootPath/partition0-replica2。
那么DAGScheduler在为该数据分区创建Task0的时候会把这些地址中的计算节点记录到Task0的locs属性。
如此一来当TaskScheduler需要调度Task0这个分布式任务的时候根据Task0的locs属性它就知道“Task0所需处理的数据分区在节点node0、node1、node2上存有副本因此如果WorkOffer是来自这3个节点的计算资源那对Task0来说就是投其所好”。
从这个例子我们就能更好地理解,每个任务都是自带本地倾向性的,换句话说,每个任务都有自己的“调度意愿”。
回到斯巴克建筑集团的类比就好比是某个“活儿”并不是所有人都能干而是只倾向于让某些人来做因为他们更专业。比如砌墙这件事更倾向于给工龄3年以上的瓦工来做而吊顶则更倾向于给经验超过5年的木工来做诸如此类。
像上面这种定向到计算节点粒度的本地性倾向Spark中的术语叫做NODE_LOCAL。除了定向到节点Task还可以定向到进程Executor、机架、任意地址它们对应的术语分别是PROCESS_LOCAL、RACK_LOCAL和ANY。
对于倾向PROCESS_LOCAL的Task来说它要求对应的数据分区在某个进程Executor中存有副本而对于倾向RACK_LOCAL的Task来说它仅要求相应的数据分区存在于同一机架即可。ANY则等同于无定向也就是Task对于分发的目的地没有倾向性被调度到哪里都可以。
下图展示的是TaskScheduler依据本地性倾向依次进行任务调度的运行逻辑
不难发现从PROCESS_LOCAL、NODE_LOCAL、到RACK_LOCAL、再到ANYTask的本地性倾向逐渐从严苛变得宽松。TaskScheduler接收到WorkerOffer之后也正是按照这个顺序来遍历TaskSet中的Tasks优先调度本地性倾向为PROCESS_LOCAL的Task而NODE_LOCAL次之RACK_LOCAL为再次最后是ANY。
你可能会问“Spark区分对待不同的本地倾向性它的初衷和意图是什么呢”实际上不同的本地性倾向本质上是用来区分计算代码与数据之间的关系。
Spark调度系统的核心思想是“数据不动、代码动”。也就是说在任务调度的过程中为了完成分布式计算Spark倾向于让数据待在原地、保持不动而把计算任务代码调度、分发到数据所在的地方从而消除数据分发引入的性能隐患。毕竟相比分发数据分发代码要轻量得多。
本地性倾向则意味着代码和数据应该在哪里“相会”PROCESS_LOCAL是在JVM进程中NODE_LOCAL是在节点内RACK_LOCAL是不超出物理机架的范围而ANY则代表“无所谓、不重要”。
好啦到此为止结合WorkerOffer与任务的本地性倾向塔斯克TaskScheduler挑选出了适合调度的“活儿”Tasks。接下来TaskScheduler就把这些Tasks通过LaunchTask消息发送给好基友SchedulerBackend。人力资源总监SchedulerBackend拿到这些活儿之后同样使用LaunchTask消息把活儿进一步下发给分公司的小弟ExecutorBackend。
那么小弟ExecutorBackend拿到活之后是怎么工作的呢我们接着往下看吧
付诸执行ExecutorBackend
作为分公司的人力资源主管ExecutorBackend拿到“活儿”之后随即把活儿派发给分公司的建筑工人。这些工人就是Executors线程池中一个又一个的CPU线程每个线程负责处理一个Task。
每当Task处理完毕这些线程便会通过ExecutorBackend向Driver端的SchedulerBackend发送StatusUpdate事件告知Task执行状态。接下来TaskScheduler与SchedulerBackend通过接力的方式最终把状态汇报给DAGScheduler如图中步骤7、8、9所示
对于同一个TaskSet当中的Tasks来说当它们分别完成了任务调度与任务执行这两个环节时也就是上图中步骤1到步骤9的计算过程Spark调度系统就完成了DAG中某一个Stage的任务调度。
不过故事到这里并未结束。我们知道一个DAG会包含多个Stages一个Stage的结束即宣告下一个Stage的开始而这也是戴格起初将DAG划分为Stages的意义所在。只有当所有的Stages全部调度、执行完毕才表示一个完整的Spark作业宣告结束。
路遥知马力,在一起合作了一个又一个建筑项目之后,空降老大戴格终于赢得了元老塔斯克和拜肯德的信任与认可,坐稳了斯巴克建筑集团的头把交椅。来日可期,戴格的前景一片光明。
重点回顾
今天这一讲我们用斯巴克建筑集团的故事介绍了Spark调度系统的工作原理。对于调度系统的工作流程你需要掌握表格中的5个关键环节
具体说来任务调度分为如下5个步骤
DAGScheduler以Shuffle为边界将开发者设计的计算图DAG拆分为多个执行阶段Stages然后为每个Stage创建任务集TaskSet。-
SchedulerBackend通过与Executors中的ExecutorBackend的交互来实时地获取集群中可用的计算资源并将这些信息记录到ExecutorDataMap数据结构。-
与此同时SchedulerBackend根据ExecutorDataMap中可用资源创建WorkerOffer以WorkerOffer为粒度提供计算资源。-
对于给定WorkerOfferTaskScheduler结合TaskSet中任务的本地性倾向按照PROCESS_LOCAL、NODE_LOCAL、RACK_LOCAL和ANY的顺序依次对TaskSet中的任务进行遍历优先调度本地性倾向要求苛刻的Task。-
被选中的Task由TaskScheduler传递给SchedulerBackend再由SchedulerBackend分发到Executors中的ExecutorBackend。Executors接收到Task之后即调用本地线程池来执行分布式任务。
今天的内容就是这些调度系统是分布式计算系统的核心掌握了Spark任务调度的来龙去脉你也就把握住了Spark分布式计算引擎的精髓这会为你开发出高性能的Spark分布式应用打下坚实基础。
每课一练
课程的最后我来给你留一道练习题。请你想一想DAGScheduler如何得知一个Stage中所有的Tasks都已调度、执行完毕然后才决定开始调度DAG中的下一个Stage
欢迎你在评论区回答这个问题。如果你觉得这一讲对你有所帮助,也欢迎你把它分享给更多的朋友和同事。我在评论区等你,咱们下一讲见!

View File

@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 Shuffle管理为什么Shuffle是性能瓶颈
你好,我是吴磊。
在上一讲我们拜访了斯巴克国际建筑集团总公司结识了Spark调度系统的三巨头DAGScheduler、TaskScheduler和SchedulerBackend。相信你已经感受到调度系统组件众多且运作流程精密而又复杂。
任务调度的首要环节是DAGScheduler以Shuffle为边界把计算图DAG切割为多个执行阶段Stages。显然Shuffle是这个环节的关键。那么我们不禁要问“Shuffle是什么为什么任务执行需要Shuffle操作Shuffle是怎样一个过程
今天这一讲我们转而去“拜访”斯巴克国际建筑集团的分公司用“工地搬砖的任务”来理解Shuffle及其工作原理。由于Shuffle的计算几乎需要消耗所有类型的硬件资源比如CPU、内存、磁盘与网络在绝大多数的Spark作业中Shuffle往往是作业执行性能的瓶颈因此我们必须要掌握Shuffle的工作原理从而为Shuffle环节的优化打下坚实基础。
什么是Shuffle
我们先不急着给Shuffle下正式的定义为了帮你迅速地理解Shuffle的含义从而达到事半功倍的效果我们不妨先去拜访斯巴克集团的分公司去看看“工地搬砖”是怎么一回事。
斯巴克集团的各家分公司分别驻扎在不同的建筑工地,每家分公司的人员配置和基础设施都大同小异:在人员方面,各家分公司都有建筑工人若干、以及负责管理这些工人的工头。在基础设施方面,每家分公司都有临时搭建、方便存取建材的临时仓库,这些仓库配备各式各样的建筑原材料,比如混凝土砖头、普通砖头、草坪砖头等等。
咱们参观、考察斯巴克建筑集团的目的毕竟还是学习Spark因此我们得把分公司的人与物和Spark的相关概念对应上这样才能方便你快速理解Spark的诸多组件与核心原理。
分公司的人与物和Spark的相关概念是这样对应的
基于图中不同概念的对应关系接下来我们来看“工地搬砖”的任务。斯巴克建筑集团的3家分公司分别接到3个不同的建筑任务。第一家分公司的建筑项目是摩天大厦第二家分公司被要求在工地上建造一座“萌宠乐园”而第三家分公司收到的任务是打造露天公园。为了叙述方便我们把三家分公司分别称作分公司1、分公司2和分公司3。
显然,不同建筑项目对于建材的选型要求是有区别的,摩天大厦的建造需要刚性强度更高的混凝土砖头,同理,露天公园的建设需要透水性好的草坪砖头,而萌宠乐园使用普通砖头即可。
可是不同类型的砖头分别散落在3家公司的临时仓库中。为了实现资源的高效利用每个分公司的施工工人们都需要从另外两家把项目特需的砖头搬运过来。对于这个过程我们把它叫作“搬砖任务”。
有了“工地搬砖”的直观对比我们现在就可以直接给Shuffle下一个正式的定义了。
Shuffle的本意是扑克的“洗牌”在分布式计算场景中它被引申为集群范围内跨节点、跨进程的数据分发。在工地搬砖的任务中如果我们把不同类型的砖头看作是分布式数据集那么不同类型的砖头在各个分公司之间搬运的过程与分布式计算中的Shuffle可以说是异曲同工。
要完成工地搬砖的任务,每位工人都需要长途跋涉到另外两家分公司,然后从人家的临时仓库把所需的砖头搬运回来。分公司之间相隔甚远,仅靠工人们一块砖一块砖地搬运,显然不现实。因此,为了提升搬砖效率,每位工人还需要借助货运卡车来帮忙。不难发现,工地搬砖的任务需要消耗大量的人力物力,可以说是劳师动众。
Shuffle的过程也是类似分布式数据集在集群内的分发会引入大量的磁盘I/O与网络I/O。在DAG的计算链条中Shuffle环节的执行性能是最差的。你可能会问“既然Shuffle的性能这么差为什么在计算的过程中非要引入Shuffle操作呢免去Shuffle环节不行吗
其实计算过程之所以需要Shuffle往往是由计算逻辑、或者说业务逻辑决定的。
比如对于搬砖任务来说不同的建筑项目就是需要不同的建材只有这样才能满足不同的施工要求。再比如在Word Count的例子中我们的“业务逻辑”是对单词做统计计数那么对单词“Spark”来说在做“加和”之前我们就是得把原本分散在不同Executors中的“Spark”拉取到某一个Executor才能完成统计计数的操作。
结合过往的工作经验我们发现在绝大多数的业务场景中Shuffle操作都是必需的、无法避免的。既然我们躲不掉Shuffle那么接下来我们就一起去探索看看Shuffle到底是怎样的一个计算过程。
Shuffle工作原理
为了方便你理解我们还是用Word Count的例子来做说明。在这个示例中引入Shuffle操作的是reduceByKey算子也就是下面这行代码完整代码请回顾[第1讲])。
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
我们先来直观地回顾一下这一步的计算过程然后再去分析其中涉及的Shuffle操作
如上图所示以Shuffle为边界reduceByKey的计算被切割为两个执行阶段。约定俗成地我们把Shuffle之前的Stage叫作Map阶段而把Shuffle之后的Stage称作Reduce阶段。在Map阶段每个Executors先把自己负责的数据分区做初步聚合又叫Map端聚合、局部聚合在Shuffle环节不同的单词被分发到不同节点的Executors中最后的Reduce阶段Executors以单词为Key做第二次聚合又叫全局聚合从而完成统计计数的任务。
不难发现Map阶段与Reduce阶段的计算过程相对清晰明了二者都是利用reduce运算完成局部聚合与全局聚合。在reduceByKey的计算过程中Shuffle才是关键。
仔细观察上图你就会发现与其说Shuffle是跨节点、跨进程的数据分发不如说Shuffle是Map阶段与Reduce阶段之间的数据交换。那么问题来了两个执行阶段之间是如何实现数据交换的呢
Shuffle中间文件
如果用一句来概括的话那就是Map阶段与Reduce阶段通过生产与消费Shuffle中间文件的方式来完成集群范围内的数据交换。换句话说Map阶段生产Shuffle中间文件Reduce阶段消费Shuffle中间文件二者以中间文件为媒介完成数据交换。
那么接下来的问题是什么是Shuffle中间文件它是怎么产生的又是如何被消费的
我把它的产生和消费过程总结在下图中了:
在上一讲介绍调度系统的时候我们说过DAGScheduler会为每一个Stage创建任务集合TaskSet而每一个TaskSet都包含多个分布式任务Task。在Map执行阶段每个Task以下简称Map Task都会生成包含data文件与index文件的Shuffle中间文件如上图所示。也就是说Shuffle文件的生成是以Map Task为粒度的Map阶段有多少个Map Task就会生成多少份Shuffle中间文件。
再者Shuffle中间文件是统称、泛指它包含两类实体文件一个是记录KeyValue键值对的data文件另一个是记录键值对所属Reduce Task的index文件。换句话说index文件标记了data文件中的哪些记录应该由下游Reduce阶段中的哪些Task简称Reduce Task消费。在上图中为了方便示意我们把首字母是S、i、c的单词分别交给下游的3个Reduce Task去消费显然这里的数据交换规则是单词首字母。
在Spark中Shuffle环节实际的数据交换规则要比这复杂得多。数据交换规则又叫分区规则因为它定义了分布式数据集在Reduce阶段如何划分数据分区。假设Reduce阶段有N个Task这N个Task对应着N个数据分区那么在Map阶段每条记录应该分发到哪个Reduce Task是由下面的公式来决定的。
P = Hash(Record Key) % N
对于任意一条数据记录Spark先按照既定的哈希算法计算记录主键的哈希值然后把哈希值对N取模计算得到的结果数字就是这条记录在Reduce阶段的数据分区编号P。换句话说这条记录在Shuffle的过程中应该被分发到Reduce阶段的P号分区。
熟悉了分区规则与中间文件之后,接下来,我们再来说一说中间文件是怎么产生的。
Shuffle Write
我们刚刚说过Shuffle中间文件是以Map Task为粒度生成的我们不妨使用下图中的Map Task以及与之对应的数据分区为例来讲解中间文件的生成过程。数据分区的数据内容如图中绿色方框所示
在生成中间文件的过程中Spark会借助一种类似于Map的数据结构来计算、缓存并排序数据分区中的数据记录。这种Map结构的Key是Reduce Task Partition IDRecord Key而Value是原数据记录中的数据值如图中的“内存数据结构”所示。
对于数据分区中的数据记录Spark会根据我们前面提到的公式1逐条计算记录所属的目标分区ID然后把主键Reduce Task Partition IDRecord Key和记录的数据值插入到Map数据结构中。当Map结构被灌满之后Spark根据主键对Map中的数据记录做排序然后把所有内容溢出到磁盘中的临时文件如图中的步骤1所示。
随着Map结构被清空Spark可以继续读取分区内容并继续向Map结构中插入数据直到Map结构再次被灌满而再次溢出如图中的步骤2所示。就这样如此往复直到数据分区中所有的数据记录都被处理完毕。
到此为止磁盘上存有若干个溢出的临时文件而内存的Map结构中留有部分数据Spark使用归并排序算法对所有临时文件和Map结构剩余数据做合并分别生成data文件、和与之对应的index文件如图中步骤4所示。Shuffle阶段生成中间文件的过程又叫Shuffle Write。
总结下来Shuffle中间文件的生成过程分为如下几个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
到目前为止我们熟悉了Spark在Map阶段生产Shuffle中间文件的过程那么在Reduce阶段不同的Tasks又是如何基于这些中间文件来定位属于自己的那部分数据从而完成数据拉取呢
Shuffle Read
首先我们需要注意的是对于每一个Map Task生成的中间文件其中的目标分区数量是由Reduce阶段的任务数量又叫并行度决定的。在下面的示意图中Reduce阶段的并行度是3因此Map Task的中间文件会包含3个目标分区的数据而index文件恰恰是用来标记目标分区所属数据记录的起始索引。
对于所有Map Task生成的中间文件Reduce Task需要通过网络从不同节点的硬盘中下载并拉取属于自己的数据内容。不同的Reduce Task正是根据index文件中的起始索引来确定哪些数据内容是“属于自己的”。Reduce阶段不同于Reduce Task拉取数据的过程往往也被叫做Shuffle Read。
好啦到此为止我们依次解答了本讲最初提到的几个问题“什么是Shuffle为什么需要Shuffle以及Shuffle是如何工作的”。Shuffle是衔接不同执行阶段的关键环节Shuffle的执行性能往往是Spark作业端到端执行效率的关键因此掌握Shuffle是我们入门Spark的必经之路。希望今天的讲解能帮你更好地认识Shuffle。
重点回顾
今天的内容比较多,我们一起来做个总结。
首先我们给Shuffle下了一个明确的定义在分布式计算场景中Shuffle指的是集群范围内跨节点、跨进程的数据分发。
我们在最开始提到Shuffle的计算会消耗所有类型的硬件资源。具体来说Shuffle中的哈希与排序操作会大量消耗CPU而Shuffle Write生成中间文件的过程会消耗宝贵的内存资源与磁盘I/O最后Shuffle Read阶段的数据拉取会引入大量的网络I/O。不难发现Shuffle是资源密集型计算因此理解Shuffle对开发者来说至关重要。
紧接着我们介绍了Shuffle中间文件。Shuffle中间文件是统称它包含两类文件一个是记录KeyValue键值对的data文件另一个是记录键值对所属Reduce Task的index文件。计算图DAG中的Map阶段与Reduce阶段正是通过中间文件来完成数据的交换。
接下来我们详细讲解了Shuffle Write过程中生成中间文件的详细过程归纳起来这个过程分为4个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
最后在Reduce阶段Reduce Task通过index文件来“定位”属于自己的数据内容并通过网络从不同节点的data文件中下载属于自己的数据记录。
每课一练
这一讲就到这里了,我在这给你留个思考题:
在Shuffle的计算过程中中间文件存储在参数spark.local.dir设置的文件目录中这个参数的默认值是/tmp你觉得这个参数该如何设置才更合理呢
欢迎你在评论区分享你的答案,我在评论区等你。如果这一讲对你有所帮助,你也可以分享给自己的朋友,我们下一讲见。

View File

@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 RDD常用算子Spark如何实现数据聚合
你好,我是吴磊。
积累了一定的理论基础之后今天我们继续来学习RDD常用算子。在[RDD常用算子]那一讲我们讲了四个算子map、mapPartitions、flatMap和filter同时留了这样一道思考题“这些算子之间有哪些共同点
今天我们就来揭晓答案。首先在功能方面这4个算子都用于RDD内部的数据转换而学习过Shuffle的工作原理之后我们不难发现这4个算子当中没有任何一个算子会引入Shuffle计算。
而今天我们要学习的几个算子则恰恰相反它们都会引入繁重的Shuffle计算。这些算子分别是groupByKey、reduceByKey、aggregateByKey和sortByKey也就是表格中加粗的部分。
我们知道在数据分析场景中典型的计算类型分别是分组、聚合和排序。而groupByKey、reduceByKey、aggregateByKey和sortByKey这些算子的功能恰恰就是用来实现分组、聚合和排序的计算逻辑。
尽管这些算子看上去相比其他算子的适用范围更窄也就是它们只能作用Apply在Paired RDD之上所谓Paired RDD它指的是元素类型为KeyValue键值对的RDD。
但是在功能方面,可以说,它们承担了数据分析场景中的大部分职责。因此,掌握这些算子的用法,是我们能够游刃有余地开发数据分析应用的重要基础。那么接下来,我们就通过一些实例,来熟悉并学习这些算子的用法。
我们先来说说groupByKey坦白地说相比后面的3个算子groupByKey在我们日常开发中的“出镜率”并不高。之所以要先介绍它主要是为后续的reduceByKey和aggregateByKey这两个重要算子做铺垫。
groupByKey分组收集
groupByKey的字面意思是“按照Key做分组”但实际上groupByKey算子包含两步即分组和收集。
具体来说对于元素类型为KeyValue键值对的Paired RDDgroupByKey的功能就是对Key值相同的元素做分组然后把相应的Value值以集合的形式收集到一起。换句话说groupByKey会把RDD的类型由RDD[(Key, Value)]转换为RDD[(Key, Value集合)]。
这么说比较抽象我们还是用一个小例子来说明groupByKey的用法。还是我们熟知的Word Count对于分词后的一个个单词假设我们不再统计其计数而仅仅是把相同的单词收集到一起那么我们该怎么做呢按照老规矩咱们还是先来给出代码实现
import org.apache.spark.rdd.RDD
// 以行为单位做分词
val cleanWordRDD: RDD[String] = _ // 完整代码请参考第一讲的Word Count
// 把普通RDD映射为Paired RDD
val kvRDD: RDD[(String, String)] = cleanWordRDD.map(word => (word, word))
// 按照单词做分组收集
val words: RDD[(String, Iterable[String])] = kvRDD.groupByKey()
结合前面的代码可以看到相比之前的Word Count我们仅需做两个微小的改动即可实现新的计算逻辑。第一个改动是把map算子的映射函数f由原来的word => word1变更为word => wordword这么做的效果是把kvRDD元素的Key和Value都变成了单词。
紧接着第二个改动我们用groupByKey替换了原先的reduceByKey。相比reduceByKeygroupByKey的用法要简明得多。groupByKey是无参函数要实现对Paired RDD的分组、收集我们仅需在RDD之上调用groupByKey()即可。
尽管groupByKey的用法非常简单但它的计算过程值得我们特别关注下面我用一张示意图来讲解上述代码的计算过程从而让你更加直观地感受groupByKey可能存在的性能隐患。
从图上可以看出为了完成分组收集对于Key值相同、但分散在不同数据分区的原始数据记录Spark需要通过Shuffle操作跨节点、跨进程地把它们分发到相同的数据分区。我们之前在[第6讲]中说了Shuffle是资源密集型计算对于动辄上百万、甚至上亿条数据记录的RDD来说这样的Shuffle计算会产生大量的磁盘I/O与网络I/O开销从而严重影响作业的执行性能。
虽然groupByKey的执行效率较差不过好在它在应用开发中的“出镜率”并不高。原因很简单在数据分析领域中分组收集的使用场景很少而分组聚合才是统计分析的刚需。
为了满足分组聚合多样化的计算需要Spark提供了3种RDD算子允许开发者灵活地实现计算逻辑它们分别是reduceByKey、aggregateByKey和combineByKey。
reduceByKey我们并不陌生第1讲的Word Count实现就用到了这个算子aggregateByKey是reduceByKey的“升级版”相比reduceByKeyaggregateByKey用法更加灵活支持的功能也更加完备。
接下来我们先来回顾reduceByKey然后再对aggregateByKey进行展开。相比aggregateByKeycombineByKey仅在初始化方式上有所不同因此我把它留给你作为课后作业去探索。
reduceByKey分组聚合
reduceByKey的字面含义是“按照Key值做聚合”它的计算逻辑就是根据聚合函数f给出的算法把Key值相同的多个元素聚合成一个元素。
在[第1讲]Word Count的实现中我们使用了reduceByKey来实现分组计数
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x: Int, y: Int) => x + y)
重温上面的这段代码你有没有觉得reduceByKey与之前讲过的map、filter这些算子有一些相似的地方没错给定处理函数f它们的用法都是“算子(f)”。只不过对于map来说我们把f称作是映射函数对filter来说我们把f称作判定函数而对于reduceByKey我们把f叫作聚合函数。
在上面的代码示例中reduceByKey的聚合函数是匿名函数(x, y) => x + y。与map、filter等算子的用法一样你也可以明确地定义带名函数f然后再用reduceByKey(f)的方式实现同样的计算逻辑。
需要强调的是给定RDD[(Key类型Value类型)]聚合函数f的类型必须是Value类型Value类型 => Value类型。换句话说函数f的形参必须是两个数值且数值的类型必须与Value的类型相同而f的返回值也必须是Value类型的数值。
咱们不妨再举一个小例子让你加深对于reduceByKey算子的理解。
接下来我们把Word Count的计算逻辑改为随机赋值、提取同一个Key的最大值。也就是在kvRDD的生成过程中我们不再使用映射函数word => (word, 1)而是改为word => (word, 随机数)然后再使用reduceByKey算子来计算同一个word当中最大的那个随机数。
你可以先停下来,花点时间想一想这个逻辑该怎么实现,然后再来参考下面的代码:
import scala.util.Random._
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, nextInt(100)))
// 显示定义提取最大值的聚合函数f
def f(x: Int, y: Int): Int = {
return math.max(x, y)
}
// 按照单词提取最大值
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey(f)
观察上面的代码片段不难发现reduceByKey算子的用法还是比较简单的只需要先定义好聚合函数f然后把它传给reduceByKey算子就行了。那么在运行时上述代码的计算又是怎样的一个过程呢
我把reduceByKey的计算过程抽象成了下图
从图中你可以看出来尽管reduceByKey也会引入Shuffle但相比groupByKey以全量原始数据记录的方式消耗磁盘与网络reduceByKey在落盘与分发之前会先在Shuffle的Map阶段做初步的聚合计算。
比如在数据分区0的处理中在Map阶段reduceByKey把Key同为Streaming的两条数据记录聚合为一条聚合逻辑就是由函数f定义的、取两者之间Value较大的数据记录这个过程我们称之为“Map端聚合”。相应地数据经由网络分发之后在Reduce阶段完成的计算我们称之为“Reduce端聚合”。
你可能会说“做了Map聚合又能怎样呢相比groupByKeyreduceByKey带来的性能收益并不算明显呀”确实就上面的示意图来说我们很难感受到reduceByKey带来的性能收益。不过量变引起质变在工业级的海量数据下相比groupByKeyreduceByKey通过在Map端大幅削减需要落盘与分发的数据量往往能将执行效率提升至少一倍。
应该说,对于大多数分组&聚合的计算需求来说只要设计合适的聚合函数f你都可以使用reduceByKey来实现计算逻辑。不过术业有专攻reduceByKey算子的局限性在于其Map阶段与Reduce阶段的计算逻辑必须保持一致这个计算逻辑统一由聚合函数f定义。当一种计算场景需要在两个阶段执行不同计算逻辑的时候reduceByKey就爱莫能助了。
比方说还是第1讲的Word Count我们想对单词计数的计算逻辑做如下调整
在Map阶段以数据分区为单位计算单词的加和
而在Reduce阶段对于同样的单词取加和最大的那个数值。
显然Map阶段的计算逻辑是sum而Reduce阶段的计算逻辑是max。对于这样的业务需求reduceByKey已无用武之地这个时候就轮到aggregateByKey这个算子闪亮登场了。
aggregateByKey更加灵活的聚合算子
老规矩算子的介绍还是从用法开始。相比其他算子aggregateByKey算子的参数比较多。要在Paired RDD之上调用aggregateByKey你需要提供一个初始值一个Map端聚合函数f1以及一个Reduce端聚合函数f2aggregateByKey的调用形式如下所示
val rdd: RDD[(Key类型Value类型)] = _
rdd.aggregateByKey(初始值)(f1, f2)
初始值可以是任意数值或是字符串而聚合函数我们也不陌生它们都是带有两个形参和一个输出结果的普通函数。就这3个参数来说比较伤脑筋的是它们之间的类型需要保持一致具体来说
初始值类型必须与f2的结果类型保持一致
f1的形参类型必须与Paired RDD的Value类型保持一致
f2的形参类型必须与f1的结果类型保持一致。
不同类型之间的一致性描述起来比较拗口,咱们不妨结合示意图来加深理解:
熟悉了aggregateByKey的用法之后接下来我们用aggregateByKey这个算子来实现刚刚提到的“先加和再取最大值”的计算逻辑代码实现如下所示
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 显示定义Map阶段聚合函数f1
def f1(x: Int, y: Int): Int = {
return x + y
}
// 显示定义Reduce阶段聚合函数f2
def f2(x: Int, y: Int): Int = {
return math.max(x, y)
}
// 调用aggregateByKey实现先加和、再求最大值
val wordCounts: RDD[(String, Int)] = kvRDD.aggregateByKey(0) (f1, f2)
怎么样是不是很简单结合计算逻辑的需要我们只需要提前定义好两个聚合函数同时保证参数之间的类型一致性然后把初始值、聚合函数传入aggregateByKey算子即可。按照惯例我们还是通过aggregateByKey在运行时的计算过程来帮你深入理解算子的工作原理
不难发现在运行时与reduceByKey相比aggregateByKey的执行过程并没有什么两样最主要的区别还是Map端聚合与Reduce端聚合的计算逻辑是否一致。值得一提的是与reduceByKey一样aggregateByKey也可以通过Map端的初步聚合来大幅削减数据量在降低磁盘与网络开销的同时提升Shuffle环节的执行性能。
sortByKey排序
在这一讲的最后我们再来说说sortByKey这个算子顾名思义它的功能是“按照Key进行排序”。给定包含KeyValue键值对的Paired RDDsortByKey会以Key为准对RDD做排序。算子的用法比较简单只需在RDD之上调用sortByKey()即可:
val rdd: RDD[(Key类型Value类型)] = _
rdd.sortByKey()
在默认的情况下sortByKey按照Key值的升序Ascending对RDD进行排序如果想按照降序Descending来排序的话你需要给sortByKey传入false。总结下来关于排序的规则你只需要记住如下两条即可
升序排序调用sortByKey()、或者sortByKey(true)
降序排序调用sortByKey(false)。
重点回顾
今天这一讲我们介绍了数据分析场景中常用的4个算子它们分别是groupByKey、reduceByKey、aggregateByKey和sortByKey掌握这些算子的用法与原理将为你游刃有余地开发数据分析应用打下坚实基础。
关于这些算子你首先需要了解它们之间的共性。一来这4个算子的作用范围都是Paired RDD二来在计算的过程中它们都会引入Shuffle。而Shuffle往往是Spark作业执行效率的瓶颈因此在使用这4个算子的时候对于它们可能会带来的性能隐患我们要做到心中有数。
再者你需要掌握每一个算子的具体用法与工作原理。groupByKey是无参算子你只需在RDD之上调用groupByKey()即可完成对数据集的分组和收集。但需要特别注意的是以全量原始数据记录在集群范围内进行落盘与网络分发会带来巨大的性能开销。因此除非必需你应当尽量避免使用groupByKey算子。
利用聚合函数freduceByKey可以在Map端进行初步聚合大幅削减需要落盘与分发的数据量从而在一定程度上能够显著提升Shuffle计算的执行效率。对于绝大多数分组&聚合的计算需求只要聚合函数f设计得当reduceByKey都能实现业务逻辑。reduceByKey也有其自身的局限性那就是其Map阶段与Reduce阶段的计算逻辑必须保持一致。
对于Map端聚合与Reduce端聚合计算逻辑不一致的情况aggregateByKey可以很好地满足这样的计算场景。aggregateByKey的用法是aggregateByKey(初始值)(Map端聚合函数Reduce端聚合函数)对于aggregateByKey的3个参数你需要保证它们之间类型的一致性。一旦类型一致性得到满足你可以通过灵活地定义两个聚合函数来翻着花样地进行各式各样的数据分析。
最后对于排序类的计算需求你可以通过调用sortByKey来进行实现。sortByKey支持两种排序方式在默认情况下sortByKey()按Key值的升序进行排序sortByKey()与sortByKey(true)的效果是一样的。如果想按照降序做排序你只需要调用sortByKey(false)即可。
到此为止我们一起学习了RDD常用算子的前两大类也就是数据转换和数据聚合。在日常的开发工作中应该说绝大多数的业务需求都可以通过这些算子来实现。
因此恭喜你毫不夸张地说学习到这里你的一只脚已经跨入了Spark分布式应用开发的大门。不过我们还不能骄傲“学会”和“学好”之间还有一定的距离在接下来的时间里期待你和我一起继续加油真正做到吃透Spark、玩转Spark
每课一练
这一讲到这里就要结束了,今天的练习题是这样的:
学习过reduceByKey和aggregateByKey之后你能说说它们二者之间的联系吗你能用aggregateByKey来实现reduceByKey的功能吗
欢迎你分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,和他一起来讨论一下本讲的练习题,我们下一讲再见。

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 内存管理Spark如何使用内存
你好,我是吴磊。
在[第6讲]我们拜访了斯巴克建筑集团的分公司熟悉了分公司的办公环境与人员配置同时用“工地搬砖的任务”作类比介绍了Spark Shuffle的工作原理。
今天这一讲我们再次来到分公司去看看斯巴克公司都在承接哪些建筑项目以及这些项目是如何施工的。通过熟悉项目的施工过程我们一起来学习Spark的内存管理。
相比其他大数据计算引擎关于Spark的特性与优势想必你听到最多的字眼就是“内存计算”。合理而又充分地利用内存资源是Spark的核心竞争力之一。因此作为开发者我们弄清楚Spark是如何使用内存的就变得非常重要。
好啦闲言少叙请你戴好安全帽跟我一起再次去拜访斯巴克集团分公司吧。不过在正式“拜访”之前我们还有一项准备工作要做那就是先了解清楚Spark的内存区域是怎样划分的。
Spark内存区域划分
对于任意一个Executor来说Spark会把内存分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
其中Reserved Memory固定为300MB不受开发者控制它是Spark预留的、用来存储各种 Spark 内部对象的内存区域User Memory用于存储开发者自定义的数据结构例如RDD算子中引用的数组、列表、映射等等。
Execution Memory用来执行分布式任务。分布式任务的计算主要包括数据的转换、过滤、映射、排序、聚合、归并等环节而这些计算环节的内存消耗统统来自于Execution Memory。
Storage Memory用于缓存分布式数据集比如RDD Cache、广播变量等等。关于广播变量的细节我们留到第10讲再去展开。RDD Cache指的是RDD物化到内存中的副本。在一个较长的DAG中如果同一个RDD被引用多次那么把这个RDD缓存到内存中往往会大幅提升作业的执行性能。我们在这节课的最后会介绍RDD Cache的具体用法。
不难发现Execution Memory和Storage Memory这两块内存区域对于Spark作业的执行性能起着举足轻重的作用。因此在所有的内存区域中Execution Memory和Storage Memory是最重要的也是开发者最需要关注的。
在 Spark 1.6 版本之前Execution Memory 和 Storage Memory的空间划分是静态的一旦空间划分完毕不同内存区域的用途与尺寸就固定了。也就是说即便你没有缓存任何 RDD 或是广播变量Storage Memory 区域的空闲内存也不能用来执行映射、排序或聚合等计算任务,宝贵的内存资源就这么白白地浪费掉了。
考虑到静态内存划分的弊端,在 1.6 版本之后Spark 推出了统一内存管理模式在这种模式下Execution Memory 和 Storage Memory 之间可以相互转化。这是什么意思呢?接下来,我们一起走进斯巴克集团分公司,看看不同内存区域相互转化的逻辑。
不同内存区域的相互转化
刚一走进分公司的大门,我们就能看到工人们在工地上如火如荼的忙碌景象。走近一问,才知道他们承接了一个“集装箱改装活动房”的建筑项目。顾名思义,这个项目的具体任务,就是把集装箱改装成活动房。
活动房的制作过程并不复杂,只需一系列简单的步骤,就能把集装箱改装为小巧而又别致的活动房,这些步骤包括清洗、切割开窗、切割开门、刷漆、打隔断、布置家居、装饰点缀。活动房的制作在工地上完成,成功改装的活动房会被立即拉走,由货运卡车运往集团公司的物流集散地。
好了介绍完集装箱改装活动房的项目我们必须要交代一下这个项目与Spark之间的关联关系。毕竟再有趣的故事也是用来辅助咱们更好地学习Spark嘛。
项目中涉及的原材料、施工步骤与Spark之间的类比关系我把它整理到了下面的这张表格中-
从表中可以看到集装箱相当于是RDD数据源而切割门窗等施工步骤对应的正是各式各样的RDD算子。而工地用于提供施工场所这与计算节点内存提供数据处理场所的作用如出一辙。这么看下来集装箱改装活动房的项目就可以看作是Spark作业或者说是Spark应用。
接下来,我们来考察一下这个项目的施工过程。走近工地,我们发现工地上赫然划着一条红色的虚线,把工地一分为二。虚线的左侧,堆放着若干沾满泥土的集装箱,而工地的右侧,则是工人们在集装箱上叮叮当当地做着改装,有的集装箱已经开始布置家居,有的还在切割门窗。
看到地上的红线,我们不免好奇,走近前去问,工头为我们道清了原委。
按理说,像集装箱、家具这些生产资料都应该放在临时仓库(节点硬盘)的,工地(节点内存)原则上只用来进行改装操作。不过,工地离临时仓库还有一段距离,来回运输不太方便。
为了提升工作效率工地被划分成两个区域。在上图中红线左边的那块地叫作暂存区Storage Memory专门用来暂存建筑材料而右边的那部分叫作操作区Execution Memory用来给工人改装集装箱、制作活动房。
之所以使用虚线标记,原因就在于,两块区域的尺寸大小并不是一成不变的,当一方区域有空地时,另一方可以进行抢占。
举例来说假设操作区只有两个工人CPU 线程)分别在改装集装箱,此时操作区空出来可以容纳两个物件的空地,那么这片空地就可以暂时用来堆放建筑材料,暂存区也因此得到了实质性的扩张。
不过当有足够的工人可以扩大生产的时候比如在原有两个工人在作业的基础上又来了两个工人此时共有4个工人可以同时制作活动房那么红色虚线到蓝色实线之间的任何物件比如上图的沙发和双人床都需要腾出到临时仓库腾空的区域交给新来的两个工人改装集装箱。毕竟改装集装箱、制作活动房才是项目的核心任务。
相反如果暂存区堆放的物件比较少、留有空地而工人又比较充裕比如有6个工人可以同时进行改装那么此时暂存区的空地就会被操作区临时征用给工人用来制作活动房。这个时候操作区实际上也扩大了。
当有更多的物件需要堆放到暂存区的时候扩张的操作区相应地也需要收缩到红色虚线的位置。不过对于红色实线与红色虚线之间的区域我们必须要等到工人们把正在改装的活动房制作完毕Task Complete才能把这片区域归还给暂存区。
好啦,活动房的项目到这里就介绍完了。不难发现,操作区类比的是 Execution Memory而暂存区其实就是 Storage Memory。Execution Memory 和 Storage Memory 之间的抢占规则,一共可以总结为 3 条:
如果对方的内存空间有空闲,双方可以互相抢占;
对于Storage Memory抢占的Execution Memory部分当分布式任务有计算需要时Storage Memory必须立即归还抢占的内存涉及的缓存数据要么落盘、要么清除
对于Execution Memory抢占的Storage Memory部分即便Storage Memory有收回内存的需要也必须要等到分布式任务执行完毕才能释放。
介绍完Execution Memory与Storage Memory之间的抢占规则之后接下来我们来看看不同内存区域的初始大小是如何设置的。
内存配置项
总体来说Executor JVM Heap的划分由图中的3个配置项来决定
其中spark.executor.memory是绝对值它指定了Executor进程的JVM Heap总大小。另外两个配置项spark.memory.fraction和spark.memory.storageFraction都是比例值它们指定了划定不同区域的空间占比。
spark.memory.fraction用于标记Spark处理分布式数据集的内存总大小这部分内存包括Execution Memory和Storage Memory两部分也就是图中绿色的矩形区域。M 300* 1 mf刚好就是User Memory的区域大小也就是图中蓝色区域的部分。
spark.memory.storageFraction则用来进一步区分Execution Memory和Storage Memory的初始大小。我们之前说过Reserved Memory固定为300MB。M 300* mf * sf是Storage Memory的初始大小相应地M 300* mf * 1 sf就是Execution Memory的初始大小。
熟悉了以上3个配置项作为开发者我们就能有的放矢地去调整不同的内存区域从而提升内存的使用效率。我们在前面提到合理地使用RDD Cache往往能大幅提升作业的执行性能因此在这一讲的最后我们一起来学习一下RDD Cache的具体用法。
RDD Cache
在一个Spark作业中计算图DAG中往往包含多个RDD我们首先需要弄清楚什么时候对哪个RDD进行Cache盲目地滥用Cache可不是明智之举。我们先说结论当同一个RDD被引用多次时就可以考虑对其进行Cache从而提升作业的执行效率。
我们拿第1讲中的Word Count来举例完整的代码如下所示
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
// 将分组计数结果落盘到文件
val targetPath: String = _
wordCounts.saveAsTextFile(targetPath)
细心的你可能发现了我们今天的代码与第1讲中的代码实现不同。我们在最后追加了saveAsTextFile落盘操作这样一来wordCounts这个RDD在程序中被引用了两次。
如果你把这份代码丢进spark-shell去执行会发现take和saveAsTextFile这两个操作执行得都很慢。这个时候我们就可以考虑通过给wordCounts加Cache来提升效率。
那么问题来了Cache该怎么加呢很简单你只需要在wordCounts完成定义之后在这个RDD之上依次调用cache和count即可如下所示
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
wordCounts.cache// 使用cache算子告知Spark对wordCounts加缓存
wordCounts.count// 触发wordCounts的计算并将wordCounts缓存到内存
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
// 将分组计数结果落盘到文件
val targetPath: String = _
wordCounts.saveAsTextFile(targetPath)
由于cache函数并不会立即触发RDD在内存中的物化因此我们还需要调用count算子来触发这一执行过程。添加上面的两条语句之后你会发现take和saveAsTextFile的运行速度明显变快了很多。强烈建议你在spark-shell中对比添加Cache前后的运行速度从而直观地感受RDD Cache对于作业执行性能的提升。
在上面的例子中我们通过在RDD之上调用cache来为其添加缓存而在背后cache函数实际上会进一步调用persistMEMORY_ONLY来完成计算。换句话说下面的两条语句是完全等价的二者的含义都是把RDD物化到内存。
wordCounts.cache
wordCounts.persist(MEMORY_ONLY)
就添加Cache来说相比cache算子persist算子更具备普适性结合多样的存储级别如这里的MEMORY_ONLYpersist算子允许开发者灵活地选择Cache的存储介质、存储形式以及副本数量。
Spark支持丰富的存储级别每一种存储级别都包含3个最基本的要素。
存储介质:数据缓存到内存还是磁盘,或是两者都有
存储形式:数据内容是对象值还是字节数组,带 SER 字样的表示以序列化方式存储,不带 SER 则表示采用对象值
副本数量:存储级别名字最后的数字代表拷贝数量,没有数字默认为 1 份副本。
我把Spark支持的存储级别总结到了下表其中打钩的地方表示某种存储级别支持的存储介质与存储形式你不妨看一看做到心中有数。
通过上表对琳琅满目的存储级别进行拆解之后我们就会发现它们不过是存储介质、存储形式和副本数量这3类基本要素的排列组合而已。上表列出了目前Spark支持的所有存储级别通过它你可以迅速对比查找不同的存储级别从而满足不同的业务需求。
重点回顾
今天这一讲你需要掌握Executor JVM Heap的划分原理并学会通过配置项来划分不同的内存区域。
具体来说Spark把Executor内存划分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
通过调整spark.executor.memory、spark.memory.fraction和spark.memory.storageFraction这3个配置项你可以灵活地调整不同内存区域的大小从而去适配Spark作业对于内存的需求。
再者在统一内存管理模式下Execution Memory与Storage Memory之间可以互相抢占你需要弄清楚二者之间的抢占逻辑。总结下来内存的抢占逻辑有如下3条
如果对方的内存空间有空闲,双方可以互相抢占;
对于Storage Memory抢占的Execution Memory部分当分布式任务有计算需要时Storage Memory必须立即归还抢占的内存涉及的缓存数据要么落盘、要么清除
对于Execution Memory抢占的Storage Memory部分即便Storage Memory有收回内存的需要也必须要等到分布式任务执行完毕才能释放。
最后我们介绍了RDD Cache的基本用法当一个RDD在代码中的引用次数大于1时你可以考虑通过给RDD加Cache来提升作业性能。具体做法是在RDD之上调用cache或是persist函数。
其中persist更具备普适性你可以通过指定存储级别来灵活地选择Cache的存储介质、存储形式以及副本数量从而满足不同的业务需要。
每课一练
好啦,这节课就到这里了,我们今天的练习题是这样的:
给定如下配置项设置请你计算不同内存区域Reserved、User、Execution、Storage的空间大小。
欢迎你在评论区分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,我们下一讲再见。

View File

@ -0,0 +1,287 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 RDD常用算子数据的准备、重分布与持久化
你好,我是吴磊。
在RDD常用算子的前两讲中我们分别介绍了用于RDD内部转换与聚合的诸多算子今天这一讲我们继续来介绍表格中剩余部分的算子。
按照惯例,表格中的算子我们不会全都介绍,而是只挑选其中最常用、最具代表性的进行讲解。今天要讲的算子,我用加粗字体进行了高亮显示,你不妨先扫一眼,做到心中有数。
你可能会觉得,这些高亮显示的算子乍一看也没什么关联啊?但如果我们从数据生命周期的角度入手,给它们归归类,很容易就会发现这些算子分别隶属于生命周期的某个阶段。
结合上图,我们分别来看看每个算子所在的生命周期和它们实现的功能。
首先在数据准备阶段union与sample用于对不同来源的数据进行合并与拆分。
我们从左往右接着看接下来是数据预处理环节。较为均衡的数据分布对后面数据处理阶段提升CPU利用率更有帮助可以整体提升执行效率。那这种均衡要怎么实现呢没错这时就要coalesce与repartition登场了它们的作用就是重新调整RDD数据分布。
在数据处理完毕、计算完成之后我们自然要对计算结果进行收集。Spark提供了两类结果收集算子一类是像take、first、collect这样把结果直接收集到Driver端另一类则是直接将计算结果持久化到分布式文件系统比如咱们这一讲会提到的saveAsTextFile。
好啦,清楚了我们今天要讲哪些算子,以及它们大致的定位与功用之后,接下来,我们就正式来讲讲这些算子的具体用法。
数据准备
首先我们先来说说数据准备阶段的union和sample。
union
在我们日常的开发中union非常常见它常常用于把两个类型一致、但来源不同的RDD进行合并从而构成一个统一的、更大的分布式数据集。例如在某个数据分析场景中一份数据源来自远端数据库而另一份数据源来自本地文件系统要将两份数据进行合并我们就需要用到union这个操作。
具体怎么使用呢我来举个例子。给定两个RDDrdd1和rdd2调用rdd1.union(rdd2)或是rdd1 union rdd2其结果都是两个RDD的并集具体代码如下
// T数据类型
val rdd1: RDD[T] = _
val rdd2: RDD[T] = _
val rdd = rdd1.union(rdd2)
// 或者rdd1 union rdd2
需要特别强调的是union操作能够成立的前提就是参与合并的两个RDD的类型必须完全一致。也就是说RDD[String]只能与RDD[String]合并到一起却无法与除RDD[String]以外的任何RDD类型如RDD[Int]、甚至是RDD[UserDefinedClass])做合并。
对于多个类型一致的RDD我们可以通过连续调用union把所有数据集合并在一起。例如给定类型一致的3个RDDrdd1、rdd2和rdd3我们可以使用如下代码把它们合并在一起。
// T数据类型
val rdd1: RDD[T] = _
val rdd2: RDD[T] = _
val rdd3: RDD[T] = _
val rdd = (rdd1.union(rdd2)).union(rdd3)
// 或者 val rdd = rdd1 union rdd2 union rdd3
不难发现union的典型使用场景是把多份“小数据”合并为一份“大数据”从而充分利用Spark分布式引擎的并行计算优势。
与之相反在一般的数据探索场景中我们往往只需要对一份数据的子集有基本的了解即可。例如对于一份体量在TB级别的数据集我们只想随机提取其部分数据然后计算这部分子集的统计值均值、方差等
那么,面对这类把“大数据”变成 “小数据”的计算需求Spark又如何进行支持呢这就要说到RDD的sample算子了。
sample
RDD的sample算子用于对RDD做随机采样从而把一个较大的数据集变为一份“小数据”。相较其他算子sample的参数比较多分别是withReplacement、fraction和seed。因此要在RDD之上完成数据采样你需要使用如下的方式来调用sample算子sample(withReplacement, fraction, seed)。
其中withReplacement的类型是Boolean它的含义是“采样是否有放回”如果这个参数的值是true那么采样结果中可能会包含重复的数据记录相反如果该值为false那么采样结果不存在重复记录。
fraction参数最好理解它的类型是Double值域为0到1其含义是采样比例也就是结果集与原数据集的尺寸比例。seed参数是可选的它的类型是Long也就是长整型用于控制每次采样的结果是否一致。光说不练假把式我们还是结合一些示例这样才能更好地理解sample算子的用法。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
// 不带seed每次采样结果都不同
rdd.sample(false, 0.1).collect
// 结果集Array(11, 13, 14, 39, 43, 63, 73, 78, 83, 88, 89, 90)
rdd.sample(false, 0.1).collect
// 结果集Array(6, 9, 10, 11, 17, 36, 44, 53, 73, 74, 79, 97, 99)
// 带seed每次采样结果都一样
rdd.sample(false, 0.1, 123).collect
// 结果集Array(3, 11, 26, 59, 82, 89, 96, 99)
rdd.sample(false, 0.1, 123).collect
// 结果集Array(3, 11, 26, 59, 82, 89, 96, 99)
// 有放回采样,采样结果可能包含重复值
rdd.sample(true, 0.1, 456).collect
// 结果集Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
rdd.sample(true, 0.1, 456).collect
// 结果集Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
我们的实验分为3组前两组用来对比添加seed参数与否的差异最后一组用于说明withReplacement参数的作用。
不难发现在不带seed参数的情况下每次调用sample之后的返回结果都不一样。而当我们使用同样的seed调用算子时不论我们调用sample多少次每次的返回结果都是一致的。另外仔细观察第3组实验你会发现结果集中有重复的数据记录这是因为withReplacement被置为true采样的过程是“有放回的”。
好啦到目前为止数据准备阶段常用的两个算子我们就讲完了。有了union和sample你就可以随意地调整分布式数据集的尺寸真正做到收放自如。
数据预处理
接下来在数据预处理阶段我们再来说说负责数据重分布的两个算子repartition和coalesce。
在了解这两个算子之前你需要先理解并行度这个概念。所谓并行度它实际上就是RDD的数据分区数量。还记得吗RDD的partitions属性记录正是RDD的所有数据分区。因此RDD的并行度与其partitions属性相一致。
开发者可以使用repartition算子随意调整提升或降低RDD的并行度而coalesce算子则只能用于降低RDD并行度。显然在数据分布的调整方面repartition灵活度更高、应用场景更多我们先对它进行介绍之后再去看看coalesce有什么用武之地。
repartition
一旦给定了RDD我们就可以通过调用repartition(n)来随意调整RDD并行度。其中参数n的类型是Int也就是整型因此我们可以把任意整数传递给repartition。按照惯例咱们还是结合示例熟悉一下repartition的用法。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
rdd.partitions.length
// 4
val rdd1 = rdd.repartition(2)
rdd1.partitions.length
// 2
val rdd2 = rdd.repartition(8)
rdd2.partitions.length
// 8
首先我们通过数组创建用于实验的RDD从这段代码里可以看到该RDD的默认并行度是4。在我们分别用2和8来调整RDD的并行度之后通过计算RDD partitions属性的长度我们发现新RDD的并行度分别被相应地调整为2和8。
看到这里你可能还有疑问“我们为什么需要调整RDD的并行度呢2和8看上去也没什么实质性的区别呀”。
在RDD那一讲[第2讲]我们介绍过每个RDD的数据分区都对应着一个分布式Task而每个Task都需要一个CPU线程去执行。
因此RDD的并行度很大程度上决定了分布式系统中CPU的使用效率进而还会影响分布式系统并行计算的执行效率。并行度过高或是过低都会降低CPU利用率从而白白浪费掉宝贵的分布式计算资源因此合理有效地设置RDD并行度至关重要。
这时你可能会追问“既然如此那么我该如何合理地设置RDD的并行度呢”坦白地说这个问题并没有固定的答案它取决于系统可用资源、分布式数据集大小甚至还与执行内存有关。
不过结合经验来说把并行度设置为可用CPU的2到3倍往往是个不错的开始。例如可分配给Spark作业的Executors个数为N每个Executors配置的CPU个数为C那么推荐设置的并行度坐落在N_C_2到N_C_3这个范围之间。
尽管repartition非常灵活你可以用它随意地调整RDD并行度但是你也需要注意这个算子有个致命的弊端那就是它会引入Shuffle。
我们知道([第6讲]详细讲过由于Shuffle在计算的过程中会消耗所有类型的硬件资源尤其是其中的磁盘I/O与网络I/O因此Shuffle往往是作业执行效率的瓶颈。正是出于这个原因在做应用开发的时候我们应当极力避免Shuffle的引入。
但你可能会说“如果数据重分布是刚需而repartition又必定会引入Shuffle我该怎么办呢”如果你想增加并行度那我们还真的只能仰仗repartitionShuffle的问题自然也就无法避免。但假设你的需求是降低并行度这个时候我们就可以把目光投向repartition的孪生兄弟coalesce。
coalesce
在用法上coalesce与repartition一样它也是通过指定一个Int类型的形参完成对RDD并行度的调整即coalesce (n)。那两者的用法到底有什么差别呢我们不妨结合刚刚的代码示例来对比coalesce与repartition。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
rdd.partitions.length
// 4
val rdd1 = rdd.repartition(2)
rdd1.partitions.length
// 2
val rdd2 = rdd.coalesce(2)
rdd2.partitions.length
// 2
可以看到在用法上coalesce与repartition可以互换二者的效果是完全一致的。不过如果我们去观察二者的DAG会发现同样的计算逻辑却有着迥然不同的执行计划。
在RDD之上调用toDebugStringSpark可以帮我们打印出当前RDD的DAG。尽管图中的打印文本看上去有些凌乱但你只要抓住其中的一个关键要点就可以了。
这个关键要点就是在toDebugString的输出文本中每一个带数字的小括号比如rdd1当中的“(2)”和“(4)”都代表着一个执行阶段也就是DAG中的Stage。而且不同的Stage之间会通过制表符Tab缩进进行区分比如图中的“(4)”显然要比“(2)”缩进了一段距离。
对于toDebugString的解读你只需要掌握到这里就足够了。学习过调度系统之后我们已经知道在同一个DAG内不同Stages之间的边界是Shuffle。因此观察上面的打印文本我们能够清楚地看到repartition会引入Shuffle而coalesce不会。
那么问题来了同样是重分布的操作为什么repartition会引入Shuffle而coalesce不会呢原因在于二者的工作原理有着本质的不同。
给定RDD如果用repartition来调整其并行度不论增加还是降低对于RDD中的每一条数据记录repartition对它们的影响都是无差别的数据分发。
具体来说给定任意一条数据记录repartition的计算过程都是先哈希、再取模得到的结果便是该条数据的目标分区索引。对于绝大多数的数据记录目标分区往往坐落在另一个Executor、甚至是另一个节点之上因此Shuffle自然也就不可避免。
coalesce则不然在降低并行度的计算中它采取的思路是把同一个Executor内的不同数据分区进行合并如此一来数据并不需要跨Executors、跨节点进行分发因而自然不会引入Shuffle。
这里我还特意准备了一张示意图更直观地为你展示repartition与coalesce的计算过程图片文字双管齐下相信你一定能够更加深入地理解repartition与coalesce之间的区别与联系。
好啦到此为止在数据预处理阶段用于对RDD做重分布的两个算子我们就讲完了。掌握了repartition和coalesce这两个算子结合数据集大小与集群可用资源你就可以随意地对RDD的并行度进行调整进而提升CPU利用率与作业的执行性能。
结果收集
预处理完成之后数据生命周期的下一个阶段是数据处理在这个环节你可以使用RDD常用算子[那一讲]介绍的各类算子,去对数据进行各式各样的处理,比如数据转换、数据过滤、数据聚合,等等。完成处理之后,我们自然要收集计算结果。
在结果收集方面Spark也为我们准备了丰富的算子。按照收集路径区分这些算子主要分为两类第一类是把计算结果从各个Executors收集到Driver端第二个类是把计算结果通过Executors直接持久化到文件系统。在大数据处理领域文件系统往往指的是像HDFS或是S3这样的分布式文件系统。
first、take和collect
我们今天要介绍的第一类算子有first、take和collect它们的用法非常简单按照老规矩我们还是使用代码示例进行讲解。这里我们结合第1讲的Word Count分别使用first、take和collect这三个算子对不同阶段的RDD进行数据探索。
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
lineRDD.first
// res1: String = Apache Spark
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
cleanWordRDD.take(3)
// res2: Array[String] = Array(Apache, Spark, From)
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
wordCounts.collect
// res3: Array[(String, Int)] = Array((Because,1), (Open,1), (impl...
其中first用于收集RDD数据集中的任意一条数据记录而take(n: Int)则用于收集多条记录记录的数量由Int类型的参数n来指定。
不难发现first与take的主要作用在于数据探索。对于RDD的每一步转换比如Word Count中从文本行到单词、从单词到KV转换我们都可以用first或是take来获取几条计算结果从而确保转换逻辑与预期一致。
相比之下collect拿到的不是部分结果而是全量数据也就是把RDD的计算结果全量地收集到Driver端。在上面Word Count的例子中我们可以看到由于全量结果较大屏幕打印只好做截断处理。
为了让你更深入地理解collect算子的工作原理我把它的计算过程画在了后面的示意图中。
结合示意图不难发现collect算子有两处性能隐患一个是拉取数据过程中引入的网络开销另一个Driver的OOM内存溢出Out of Memory
网络开销很好理解既然数据的拉取和搬运是跨进程、跨节点的那么和Shuffle类似这个过程必然会引入网络开销。
再者通常来说Driver端的预设内存往往在GB量级而RDD的体量一般都在数十GB、甚至上百GB因此OOM的隐患不言而喻。collect算子尝试把RDD全量结果拉取到Driver当结果集尺寸超过Driver预设的内存大小时Spark自然会报OOM的异常Exception
正是出于这些原因我们在使用collect算子之前务必要慎重。不过你可能会问“如果业务逻辑就是需要收集全量结果而collect算子又不好用那我该怎么办呢”别着急我们接着往下看。
saveAsTextFile
对于全量的结果集我们还可以使用第二类算子把它们直接持久化到磁盘。在这类算子中最具代表性的非saveAsTextFile莫属它的用法非常简单给定RDD我们直接调用saveAsTextFile(path: String)即可。其中path代表的是目标文件系统目录它可以是本地文件系统也可以是HDFS、Amazon S3等分布式文件系统。
为了让你加深对于第二类算子的理解我把它们的工作原理也整理到了下面的示意图中。可以看到以saveAsTextFile为代表的算子直接通过Executors将RDD数据分区物化到文件系统这个过程并不涉及与Driver端的任何交互。
由于数据的持久化与Driver无关因此这类算子天然地避开了collect算子带来的两个性能隐患。
好啦到此为止用于结果收集的算子我们就介绍完了掌握了first、take、collect和saveAsTextFile等算子之后你可以先用first、take等算子验证计算逻辑的正确性然后再使用saveAsTextFile算子把全量结果持久化到磁盘以备之后使用。
重点回顾
今天这一讲我们介绍并讲解了很多RDD算子这些算子可以分别归类到数据生命周期的不同阶段算子与阶段的对应关系如下图所示。
在数据准备阶段你可以使用union和sample来扩张或是缩小分布式数据集需要特别注意的是参与union的多个RDD在类型上必须保持一致。
在数据预处理阶段你可以利用repartition和coalesce来调整RDD的并行度。RDD并行度对于CPU利用率至关重要它在很大程度上决定着并行计算的执行效率。一般来说给定Executors个数N以及CPU/Executor配置个数C那么我会推荐你把RDD的并行度设置在N_C_2到N_C_3之间。
最后在结果收集阶段你可以使用first、take、collect等算子来探索数据这些算子可以用来验证计算过程中的转换逻辑是否与预期一致。当你确认计算逻辑准确无误之后就可以使用saveAsTextFile等算子将全量结果集持久化到分布式文件系统。
到今天为止我们用三讲的篇幅学习了RDD开发API中的大部分算子。灵活地运用这些算子你就能轻松应对日常开发中大部分的业务需求。为了方便你随时回顾、查阅我把我们一起学过的这些算子整理到了后面的表格中希望对你有所帮助。
每课一练
给定3个RDD除了使用rdd1 union rdd2 union rdd3把它们合并在一起之外你认为还有其他更加优雅的写法吗提示reduce
相比repartitioncoalesce有哪些可能的潜在隐患提示数据分布
欢迎你在留言区跟我交流互动也推荐你把这一讲分享给更多的同事、朋友帮他理清RDD的常用算子。

View File

@ -0,0 +1,136 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 存储系统:数据到底都存哪儿了?
你好,我是吴磊。
感谢你在国庆假期仍然坚持学习今天这一讲我们来学习存储系统与调度系统一样它也是Spark重要的基础设施之一。不过你可能会好奇“掌握Spark应用开发需要去了解这么底层的知识吗”坦白地说还真需要为什么这么说呢
我们前面学了Shuffle管理、RDD Cache和广播变量这些功能与特性对Spark作业的执行性能有着至关重要的影响。而想要实现这些功能底层的支撑系统正是Spark存储系统。
学习和熟悉存储系统不单单是为了完善我们的知识体系它还能直接帮你更好地利用RDD Cache和广播变量这些特性。在未来这些知识也能为你做Shuffle的调优奠定良好的基础。
既然存储系统这么重要,那要怎样高效快速地掌握它呢?本着学以致用的原则,我们需要先了解系统的服务对象,说白了就是存储系统是用来存什么东西的。
服务对象
笼统地说Spark存储系统负责维护所有暂存在内存与磁盘中的数据这些数据包括Shuffle中间文件、RDD Cache以及广播变量。
对于上述三类数据我们并不陌生。我们先回顾一下什么是Shuffle中间文件在Shuffle的计算过程中Map Task在Shuffle Write阶段生产data与index文件。接下来根据index文件提供的分区索引Shuffle Read阶段的Reduce Task从不同节点拉取属于自己的分区数据。而Shuffle中间文件指的正是两个阶段为了完成数据交换所仰仗的data与index文件。
RDD Cache指的是分布式数据集在内存或是磁盘中的物化它往往有利于提升计算效率。广播变量[上一讲]我们刚刚介绍过它的优势在于以Executors为粒度分发共享变量从而大幅削减数据分发引入的网络与存储开销。
我们刚才对这三类数据做了简单回顾如果你觉得哪里不是特别清楚的话不妨翻回前面几讲再看一看我们在第7、8、10这3讲分别对它们做了详细讲解。好啦了解了存储系统服务的主要对象以后接下来我们来细数Spark存储系统都有哪些重要组件看看它们之间又是如何协作的。
存储系统的构成
理论的学习总是枯燥而又乏味为了让你更加轻松地掌握存储系统的核心组件咱们不妨还是用斯巴克国际建筑集团的类比来讲解Spark存储系统。
相比调度系统复杂的人事关系(戴格、塔斯克、拜肯德),存储系统的人员构成要简单得多。在内存管理[那一讲],我们把节点内存看作是施工工地,而把节点磁盘看作是临时仓库,那么显然,管理数据存储的组件,就可以看成是仓库管理员,简称库管。
布劳克家族
在斯巴克建筑集团,库管这个关键角色,一直以来都是由布劳克家族把持着。
布劳克家族在斯巴克集团的地位举足轻重老布劳克BlockManagerMaster坐镇集团总公司Driver而他的子嗣们、小布劳克BlockManager则驻守在各个分公司Executors
对集团公司建材与仓库的整体情况,老布劳克了如指掌,当然,这一切要归功于他众多的子嗣们。各家分公司的小布劳克,争先恐后地向老爸汇报分公司的建材状态与仓库状况。关于他们的父子关系,我整理到了下面的示意图中。
从上图我们能够看得出来,小布劳克与老布劳克之间的信息交换是双向的。不难发现,布劳克家族的家风是典型的“家长制”和“一言堂”。如果小布劳克需要获取其他分公司的状态,他必须要通过老布劳克才能拿到这些信息。
在前面的几讲中我们把建材比作是分布式数据集那么BlockManagerMaster与BlockManager之间交换的信息实际上就是Executors之上数据的状态。说到这里你可能会问“既然BlockManagerMaster的信息都来自于BlockManager那么BlockManager又是从哪里获取到这些信息的呢”要回答这个问题我们还要从BlockManager的职责说起。
我们开头说过存储系统的服务对象有3个分别是Shuffle中间文件、RDD Cache以及广播变量而BlockManager的职责正是在Executors中管理这3类数据的存储、读写与收发。就存储介质来说这3类数据所消耗的硬件资源各不相同。
具体来说Shuffle中间文件消耗的是节点磁盘而广播变量主要占用节点的内存空间RDD Cache则是“脚踏两条船”既可以消耗内存也可以消耗磁盘。
不管是在内存、还是在磁盘这些数据都是以数据块Blocks为粒度进行存取与访问的。数据块的概念与RDD数据分区Partitions是一致的在RDD的上下文中说到数据划分的粒度我们往往把一份数据称作“数据分区”。而在存储系统的上下文中对于细分的一份数据我们称之为数据块。
有了数据块的概念我们就可以进一步细化BlockManager的职责。BlockManager的核心职责在于管理数据块的元数据Meta data这些元数据记录并维护数据块的地址、位置、尺寸以及状态。为了让你直观地感受一下元数据我把它的样例放到了下面的示意图里你可以看一看。
只有借助元数据BlockManager才有可能高效地完成数据的存与取、收与发。这就回答了前面我提出的问题BlockManager与数据状态有关的所有信息全部来自于元数据的管理。那么接下来的问题是结合这些元数据BlockManager如何完成数据的存取呢
不管是工地上,还是仓库里,这些场所都是尘土飞扬、人来人往,像存取建材这种事情,养尊处优的小布劳克自然不会亲力亲为。于是,他招募了两个帮手,来帮他打理这些脏活累活。
这两个帮手也都不是外人一个是大表姐迈美瑞MemoryStore另一个是大表哥迪斯克DiskStore。顾名思义MemoryStore负责内存中的数据存取而相应地DiskStore则负责磁盘中的数据访问。
好啦到此为止存储系统的重要角色已经悉数登场我把他们整理到了下面的表格中。接下来我们以RDD Cache和Shuffle中间文件的存取为例分别说一说迈美瑞和迪斯克是如何帮助小布劳克来打理数据的。
MemoryStore内存数据访问
大表姐迈美瑞秀外慧中做起事情来井井有条。为了不辜负小布劳克的托付迈美瑞随身携带着一本小册子这本小册子密密麻麻记满了关于数据块的详细信息。这个小册子是一种特别的数据结构LinkedHashMap[BlockId, MemoryEntry]。顾名思义LinkedHashMap是一种Map其中键值对的Key是BlockIdValue是MemoryEntry。
BlockId用于标记Block的身份需要注意的是BlockId不是一个仅仅记录Id的字符串而是一种记录Block元信息的数据结构。BlockId这个数据结构记录的信息非常丰富包括Block名字、所属RDD、Block对应的RDD数据分区、是否为广播变量、是否为Shuffle Block等等。
MemoryEntry是对象它用于承载数据实体数据实体可以是某个RDD的数据分区也可以是广播变量。存储在LinkedHashMap当中的MemoryEntry相当于是通往数据实体的地址。
不难发现BlockId和MemoryEntry一起就像是居民户口簿一样完整地记录了存取某个数据块所需的所有元信息相当于“居民姓名”、“所属派出所”、“家庭住址”等信息。基于这些元信息我们就可以像“查户口”一样有的放矢、精准定向地对数据块进行存取访问。
val rdd: RDD[_] = _
rdd.cache
rdd.count
以RDD Cache为例当我们使用上述代码创建RDD缓存的时候Spark会在后台帮我们做如下3件事情这个过程我把它整理到了下面的示意图中你可以看一看。
以数据分区为粒度计算RDD执行结果生成对应的数据块
将数据块封装到MemoryEntry同时创建数据块元数据BlockId
BlockIdMemoryEntry键值对添加到“小册子”LinkedHashMap。
随着RDD Cache过程的推进LinkedHashMap当中的元素会越积越多当迈美瑞的小册子完成记录的时候Spark就可以通过册子上的“户口簿”来访问每一个数据块从而实现对RDD Cache的读取与访问。
DiskStore磁盘数据访问
说完大表姐,接下来,我们再来说说大表哥迪斯克。迪斯克的主要职责,是通过维护数据块与磁盘文件的对应关系,实现磁盘数据的存取访问。相比大表姐的一丝不苟、亲力亲为,迪斯克要“鸡贼”得多,他跟布劳克一样,都是甩手掌柜。
看到大表姐没日没夜地盯着自己的“小册子”迪斯克可不想无脑地给布劳克卖命于是他招募了一个帮手DiskBlockManager来帮他维护元数据。
有了DiskBlockManager这个帮手给他打理各种杂事迪斯克这个家伙就可以哼着小曲、喝着咖啡坐在仓库门口接待来来往往的施工工人就好了。这些工人有的存货有的取货但不论是干什么的迪斯克会统一把他们打发到DiskBlockManager那里去让DiskBlockManager告诉他们货物都存在哪些货架的第几层。
帮手DiskBlockManager是类对象它的getFile方法以BlockId为参数返回磁盘文件。换句话说给定数据块要想知道它存在了哪个磁盘文件需要调用getFile方法得到答案。有了数据块与文件之间的映射关系我们就可以轻松地完成磁盘中的数据访问。
以Shuffle为例在Shuffle Write阶段每个Task都会生成一份中间文件每一份中间文件都包括带有data后缀的数据文件以及带着index后缀的索引文件。那么对于每一份文件来说我们都可以通过DiskBlockManager的getFile方法来获取到对应的磁盘文件如下图所示。
可以看到获取data文件与获取index文件的流程是完全一致的他们都是使用BlockId来调用getFile方法从而完成数据访问。
重点回顾
今天这一讲我们重点讲解了Spark存储系统。关于存储系统你首先需要知道是RDD Cache、Shuffle中间文件与广播变量这三类数据是存储系统最主要的服务对象。
接着我们介绍了存储系统的核心组件它们是坐落在Driver端的BlockManagerMaster以及“驻守”在Executors的BlockManager、MemoryStore和DiskStore。BlockManagerMaster与众多BlockManager之间通过心跳来完成信息交换这些信息包括数据块的地址、位置、大小和状态等等。
在Executors中BlockManager通过MemoryStore来完成内存的数据存取。MemoryStore通过一种特殊的数据结构LinkedHashMap来完成BlockId到MemoryEntry的映射。其中BlockId记录着数据块的元数据而MemoryEntry则用于封装数据实体。
与此同时BlockManager通过DiskStore来实现磁盘数据的存取与访问。DiskStore并不直接维护元数据列表而是通过DiskBlockManager这个对象来完成从数据库到磁盘文件的映射进而完成数据访问。
每课一练
LinkedHashMap是一种很特殊的数据结构在今天这一讲我们仅介绍了它在Map方面的功用。你可以试着自己梳理一下LinkedHashMap这种数据结构的特点与特性。
期待在留言区看到你的思考。如果这一讲对你有帮助,也推荐你转发给更多的同事、朋友。我们下一讲见!

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 基础配置详解:哪些参数会影响应用程序稳定性?
你好,我是吴磊。
国庆假期即将结束我们的基础模块也即将收尾。到目前为止我们一起学习了RDD编程模型、Spark分布式部署、Spark工作原理以及RDD常用算子。恭喜你到这里可以说你已经完全跨入了Spark分布式应用开发的大门。有了现在的知识储备对于大多数的业务需求我相信你都能很快地实现。
不过,快速用代码实现各式各样的业务需求,这还只是第一步。我们不光要让代码跑起来,还需要让代码跑得又快又稳。
要想做到这些我们还需要配置项来帮忙。如果把Spark看作是一部F1赛车的话那么配置项就是赛车车身的各项配置参数如发动机缸数、最大转矩、车身轴距、悬挂方式、整车装备质量等等。只有合理地配置车身参数才能让车子本身的稳定性和性能得到保障为选手的出色发挥奠定基础。
今天这一讲我们就来说一说Spark都有哪些配置项以及这些配置项的含义与作用。
配置项
打开Spark官网的Configuration页面在这里你能找到全部的Spark配置项。
不过让人沮丧的是配置项数目过于庞大种类繁多有的需要设置true/false有的则需要我们给出明确的数值让人看上去眼花缭乱、无所适从。
那么问题来了,面对这么多的配置项,我们应该从哪里入手呢?别着急,既然我们的目的是让车子“跑得稳”、“跑得快”,那咱们不妨从这两个角度出发,来整理那些我们必须要掌握的配置项。
在这一讲咱们先来梳理那些能让Spark跑得稳的配置项而在后续介绍Spark SQL的时候我们再去关注那些与“跑得快”有关的部分。
关于跑得稳这件事你可能会有这样的疑问“一般的车子出厂就能开并不需要特别调整什么车辆参数。同理大部分Spark配置项都有默认值开发者使用出厂设置、省去调参的麻烦它不香吗” 遗憾的是对于大多数的应用场景来说在默认的参数设置下Spark还真就跑不起来。
以spark.executor.memory这个配置项为例它用于指定Executor memory也就是Executor可用内存上限。这个参数的默认值是1GB显然对于动辄上百GB、甚至上TB量级的工业级数据来说这样的设置太低了分布式任务很容易因为OOM内存溢出Out of memory而中断。
你看为了能让Spark跑得稳咱们还是得花些心思。对于刚才说的情况如果你以为直接把内存参数设置到上百GB就可以一劳永逸那未免有些草率。单纯从资源供给的角度去调整配置项参数是一种“简单粗暴”的做法并不可取。实际上应用程序运行得稳定与否取决于硬件资源供给与计算需要是否匹配。
这就好比是赛车组装,要得到一辆高性能的车子,我们并不需要每一个部件都达到“顶配”的要求,而是要让组装配件之间相互契合、匹配,才能让车子达到预期的马力输出。
因此,咱们不妨从硬件资源的角度切入,去探索开发者必须要关注的配置项都有哪些。既然上面我们用内存举例,而且关于内存的配置项,我们在内存管理那一讲简单提过,你可能还有一些印象,那么接下来,我们就从内存入手,说一说和它有关的配置项。
内存
说起内存咱们不妨先来回顾一下Spark的内存划分。对于给定的Executor MemorySpark将JVM Heap划分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory如下图所示。
不同内存区域的含义和它们的计算公式,我们在[第8讲]做过详细讲解,如果你印象不深了可以回顾一下,这里我们重点分析一下这些内存配置项数值的设置思路。
结合图解其中Reserved Memory大小固定为300MB其他3个区域的空间大小则有3个配置项来划定它们分别是spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction。
为了后续叙述方便我们分别把它们简称为M、mf和sf其中大写的M是绝对值而小写的mf和sf都是比例值这一点需要你注意。
其中M用于指定划分给Executor进程的JVM Heap大小也即是Executor Memory。Executor Memory由Execution Memory、Storage Memory和User Memory“这三家”瓜分。
M 300* mf划分给Execution Memory和Storage Memory而User Memory空间大小由M 300*1 - mf这个公式划定它用于存储用户自定义的数据结构比如RDD算子中包含的各类实例化对象或是集合类型如数组、列表等都属于这个范畴。
因此如果你的分布式应用并不需要那么多自定义对象或集合数据你应该把mf的值设置得越接近1越好这样User Memory无限趋近于0大面积的可用内存就可以都留给Execution Memory和Storage Memory了。
我们知道在1.6版本之后Spark推出了统一的动态内存管理模式在对方资源未被用尽的时候Execution Memory与Storage Memory之间可以互相进行抢占。不过即便如此我们仍然需要sf这个配置项来划定它们之间的那条虚线从而明确告知Spark我们开发者更倾向于“偏袒”哪一方。
那么对于sf的设置开发者该如何进行取舍呢答案是看数据的复用频次。这是什么意思呢我们分场景举例来说。
对于ETLExtract、Transform、Load类型的作业来说数据往往都是按照既定的业务逻辑依序处理其中绝大多数的数据形态只需访问一遍很少有重复引用的情况。
因此在ETL作业中RDD Cache并不能起到提升执行性能的作用那么自然我们也就没必要使用缓存了。在这种情况下我们就应当把sf的值设置得低一些压缩Storage Memory可用空间从而尽量把内存空间留给Execution Memory。
相反如果你的应用场景是机器学习、或是图计算这些计算任务往往需要反复消耗、迭代同一份数据处理方式就不一样了。在这种情况下咱们要充分利用RDD Cache提供的性能优势自然就要把sf这个参数设置得稍大一些从而让Storage Memory有足够的内存空间来容纳需要频繁访问的分布式数据集。
好啦到此为止对于内存的3个配置项我们分别解读了它们的含义以及设置的一般性原则。你需要根据你的应用场景合理设置这些配置项这样程序运行才会高速、稳定。学会了这些内存配置项这一关你基本上已经拿到80分了。而剩下的20分需要你从日常开发的反复实践中去获取期待你总结归纳出更多的配置经验。
在硬件资源方面内存的服务对象是CPU。内存的有效配置一方面是为了更好地容纳数据另一方面更重要的就是提升CPU的利用率。那说完内存接下来我们再来看看CPU。
CPU
与CPU直接相关的配置项我们只需关注两个参数它们分别是spark.executor.instances和spark.executor.cores。其中前者指定了集群内Executors的个数而后者则明确了每个Executors可用的CPU CoresCPU核数
我们知道一个CPU Core在同一时间只能处理一个分布式任务因此spark.executor.instances与spark.executor.cores的乘积实际上决定了集群的并发计算能力这个乘积我们把它定义为“并发度”Degree of concurrency
说到并发度我们就不得不说另外一个概念并行度Degree of parallism。相比并发度并行度是一个高度相关、但又完全不同的概念。并行度用于定义分布式数据集划分的份数与粒度它直接决定了分布式任务的计算负载。并行度越高数据的粒度越细数据分片越多数据越分散。
这也就解释了并行度为什么总是跟分区数量、分片数量、Partitions 这些属性相一致。举个例子第9讲我们就说过并行度对应着RDD的数据分区数量。
与并行度相关的配置项也有两个分别是spark.default.parallelism和spark.sql.shuffle.partitions。其中前者定义了由SparkContext.parallelize API所生成RDD的默认并行度而后者则用于划定Shuffle过程中Shuffle Read阶段Reduce阶段的默认并行度。
对比下来,并发度的出发点是计算能力,它与执行内存一起,共同构成了计算资源的供给水平,而并行度的出发点是数据,它决定着每个任务的计算负载,对应着计算资源的需求水平。一个是供给,一个是需求,供需的平衡与否,直接影响着程序运行的稳定性。
CPU、内存与数据的平衡
由此可见所谓供需的平衡实际上就是指CPU、内存与数据之间的平衡。那么问题来了有没有什么量化的办法来让三者之间达到供需之间平衡的状态呢其实只需要一个简单的公式我们就可以轻松地做到这一点。
为了叙述方便我们把由配置项spark.executor.cores指定的CPU Cores记为c把Execution Memory内存大小记为m还记得吗m的尺寸由公式M - 300* mf *1 - sf给出。不难发现c和m一同量化了一个Executor的可用计算资源。
量化完资源供给我们接着再来说数据。对于一个待计算的分布式数据集我们把它的存储尺寸记为D而把其并行度记录为P。给定D和P不难推出D/P就是分布式数据集的划分粒度也就是每个数据分片的存储大小。
学习过调度系统我们知道在Spark分布式计算的过程中一个数据分片对应着一个Task分布式任务而一个Task又对应着一个CPU Core。因此把数据看作是计算的需求方要想达到CPU、内存与数据这三者之间的平衡我们必须要保证每个Task都有足够的内存来让CPU处理对应的数据分片。
为此我们要让数据分片大小与Task可用内存之间保持在同一量级具体来说我们可以使用下面的公式来进行量化。
D/P ~ m/c
其中波浪线的含义是其左侧与右侧的表达式在同一量级。左侧的表达式D/P为数据分片大小右侧的m/c为每个Task分到的可用内存。以这个公式为指导结合分布式数据集的存储大小我们就可以有的放矢、有迹可循地对上述的3类配置项进行设置或调整也就是与CPU、内存和并行度有关的那几个配置项。
磁盘
说完了CPU和内存接下来我们再来说说磁盘。与前两者相比磁盘的配置项相对要简单得多值得我们关注的仅有spark.local.dir这一个配置项为了叙述方便后续我们把它简称为ld。这个配置项的值可以是任意的本地文件系统目录它的默认值是/tmp目录。
ld参数对应的目录用于存储各种各样的临时数据如Shuffle中间文件、RDD Cache存储级别包含“disk”等等。这些临时数据对程序能否稳定运行有着至关重要的作用。
例如Shuffle中间文件是Reduce阶段任务执行的基础和前提如果中间文件丢失Spark在Reduce阶段就会抛出“Shuffle data not found”异常从而中断应用程序的运行。
既然这些临时数据不可或缺,我们就不能盲从默认选项了,而是有必要先考察下/tmp目录的情况。遗憾的是ld参数默认的/tmp目录一来存储空间有限二来该目录本身的稳定性也值得担忧。因此在工业级应用中我们通常都不能接受使用/tmp目录来设置ld配置项。
了解了ld这个配置项的作用之后我们自然就能想到应该把它设置到一个存储空间充沛、甚至性能更有保障的文件系统比如空间足够大的SSDSolid State Disk文件系统目录。
好啦到此为止我们分别介绍了与CPU、内存、磁盘有关的配置项以及它们的含义、作用与设置技巧。说到这里你可能有些按捺不住“这些配置项的重要性我已经get到了那我应该在哪里设置它们呢”接下来我们继续来说说开发者都可以通过哪些途径来设置配置项。
配置项的设置途径
为了满足不同的应用场景Spark为开发者提供了3种配置项设置方式分别是配置文件、命令行参数和SparkConf对象这些方式都以KeyValue键值对的形式记录并设置配置项。
配置文件指的是spark-defaults.conf这个文件存储在Spark安装目录下面的conf子目录。该文件中的参数设置适用于集群范围内所有的应用程序因此它的生效范围是全局性的。对于任意一个应用程序来说如果开发者没有通过其他方式设置配置项那么应用将默认采用spark-defaults.conf中的参数值作为基础设置。
在spark-defaults.conf中设置配置项你只需要用空格把配置项的名字和它的设置值分隔开即可。比如以spark.executor.cores、spark.executor.memory和spark.local.dir这3个配置项为例我们可以使用下面的方式对它们的值进行设置。
spark.executor.cores 2
spark.executor.memory 4g
spark.local.dir /ssd_fs/large_dir
不过在日常的开发工作中不同应用对于资源的诉求是不一样的有些需要更多的CPU Cores有些则需要更高的并行度凡此种种、不一而足可谓是众口难调这个时候我们只依赖spark-defaults.conf来进行全局设置就不灵了。
为此Spark为开发者提供了两种应用级别的设置方式也即命令行参数和SparkConf对象它们的生效范围仅限于应用本身我们分别看看这两种方式具体怎么用。
先说命令行参数它指的是在运行了spark-shell或是spark-submit命令之后通过conf关键字来设置配置项。我们知道spark-shell用于启动交互式的分布式运行环境而spark-submit则用于向Spark计算集群提交分布式作业。
还是以刚刚的3个配置项为例以命令行参数的方式进行设置的话你需要在提交spark-shell或是spark-submit命令的时候conf Key=Value的形式对参数进行赋值。
spark-shell --master local[*] --conf spark.executor.cores=2 --conf spark.executor.memory=4g --conf spark.local.dir=/ssd_fs/large_dir
不难发现尽管这种方式能让开发者在应用级别灵活地设置配置项但它的书写方式过于繁琐每个配置项都需要以conf作前缀。不仅如此命令行参数的设置方式不利于代码管理随着时间的推移参数值的设置很可能会随着数据量或是集群容量的变化而变化但是这个变化的过程却很难被记录并维护下来而这无疑会增加开发者与运维同学的运维成本。
相比之下不论是隔离性还是可维护性SparkConf对象的设置方式都更胜一筹。在代码开发的过程中我们可以通过定义SparkConf对象并调用其set方法来对配置项进行设置。老规矩还是用刚刚的CPU、内存和磁盘3个配置项来举例。
import org.apache.spark.SparkConf
val conf = new SparkConf()
conf.set("spark.executor.cores", "2")
conf.set("spark.executor.memory", "4g")
conf.set("spark.local.dir", "/ssd_fs/large_dir")
好啦到此为止我们一起梳理了CPU、内存、磁盘的相关配置项并重点强调了CPU、内存与数据之间的供需平衡。掌握了这些设置方法与要点之后你不妨自己动手去试试这些配置项可以拿之前的Word Count小例子练练手巩固一下今天所学的内容。
重点回顾
今天这一讲我们分别从CPU、内存和磁盘三个方面讲解了影响应用程序稳定性的几个重要参数。你需要掌握它们的含义、作用还有适用场景为了方便你记忆我把它们整理到后面的表格里你可以随时拿来参考。
熟悉了这些关键配置项之后你还需要了解它们的设置方式。Spark提供了3种配置项设置途径分别是spark-defaults.conf配置文件、命令行参数和SparkConf对象。其中第一种方式用于全局设置而后两者的适用范围是应用本身。
对于这3种方式Spark会按照“SparkConf对象 -> 命令行参数 -> 配置文件”的顺序依次读取配置项的参数值。对于重复设置的配置项Spark以前面的参数取值为准。
每课一练
请你粗略地过一遍Spark官网中的 Configuration页面说一说其中哪些配置项适合在spark-defaults.conf中进行设置而哪些配置项使用SparkConf对象的方式来设置比较好
欢迎你在留言区跟我交流。如果这一讲对你有帮助的话,也推荐你把这节课分享给有需要的的同事、朋友,我们下一讲见。

View File

@ -0,0 +1,222 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 Spark SQL让我们从“小汽车摇号分析”开始
你好,我是吴磊。
在开篇词我们提出“入门Spark需要三步走”到目前为止我们携手并肩跨越了前面两步首先恭喜你学到这里熟练掌握了Spark常用算子与核心原理以后你已经可以轻松应对大部分数据处理需求了。
不过数据处理毕竟是比较基础的数据应用场景就像赛车有着不同的驾驶场景想成为Spark的资深赛车手我们还要走出第三步——学习Spark计算子框架。只有完成这一步我们才能掌握Spark SQLStructured Streaming和Spark MLlib的常规开发方法游刃有余地应对不同的数据应用场景如数据分析、流计算和机器学习等等。
那这么多子框架从哪里入手比较好呢在所有的子框架中Spark SQL是代码量最多、Spark社区投入最大、应用范围最广、影响力最深远的那个。就子框架的学习来说我们自然要从Spark SQL开始。
今天我们从一个例子入手在实战中带你熟悉数据分析开发的思路和实现步骤。有了对Spark SQL的直观体验我们后面几讲还会深入探讨Spark SQL的用法、特性与优势让你逐步掌握Spark SQL的全貌。
业务需求
今天我们要讲的小例子来自于北京市小汽车摇号。我们知道为了限制机动车保有量从2011年开始北京市政府推出了小汽车摇号政策。随着摇号进程的推进在2016年为了照顾那些长时间没有摇中号码牌的“准司机”摇号政策又推出了“倍率”制度。
所谓倍率制度,它指的是,结合参与摇号次数,为每个人赋予不同的倍率系数。有了倍率加持,大家的中签率就由原来整齐划一的基础概率,变为“基础概率 * 倍率系数”。参与摇号的次数越多,倍率系数越大,中签率也会相应得到提高。
不过身边无数的“准司机”总是跟我说其实倍率这玩意没什么用背了8倍、10倍的倍率照样摇不上那么今天这一讲咱们就来借着学习Spark SQL的机会用数据来为这些还没摸过车的“老司机”答疑解惑帮他们定量地分析一下倍率与中签率之间到底有没有关系
准备工作
巧妇难为无米之炊既然是做数据分析那咱们得先有数据才行。我这边为你准备了2011年到2019年北京市小汽车的摇号数据你可以通过这个地址从网盘进行下载提取码为ajs6。
这份数据的文件名是“2011-2019 小汽车摇号数据.tar.gz”解压之后的目录结构如下图所示。
可以看到根目录下有apply和lucky两个子目录apply目录的内容是 2011-2019 年各个批次参与摇号的申请号码而lucky目录包含的是各个批次中签的申请号码。为了叙述方便我们把参与过摇号的人叫“申请者”把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次而摇号批次目录下包含的是Parquet格式的数据文件。
数据下载、解压完成之后,接下来,我们再来准备运行环境。
咱们的小例子比较轻量Scala版本的代码实现不会超过20行再者摇号数据体量很小解压之后的Parquet文件总大小也不超过4G。
选择这样的例子也是为了轻装上阵避免你因为硬件限制而难以实验。想要把用于分析倍率的应用跑起来你在笔记本或是PC上通过启动本地spark-shell环境就可以。不过如果条件允许的话我还是鼓励你搭建分布式的物理集群。关于分布式集群的搭建细节你可以参考[第4讲]。
好啦,准备好数据与运行环境之后,接下来,我们就可以步入正题,去开发探索倍率与中签率关系的数据分析应用啦。
数据探索
不过先别忙着直接上手数据分析。在此之前我们先要对数据模式Data Schema有最基本的认知也就是源数据都有哪些字段这些字段的类型和含义分别是什么这一步就是我们常说的数据探索。
数据探索的思路是这样的首先我们使用SparkSession的read API读取源数据、创建DataFrame。然后通过调用DataFrame的show方法我们就可以轻松获取源数据的样本数据从而完成数据的初步探索代码如下所示。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 数据打印
applyNumbersDF.show
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 数据打印
luckyDogsDF.show
看到这里想必你已经眉头紧锁“SparkSessionDataFrame这些都是什么鬼你好像压根儿也没有提到过这些概念呀”别着急对于这些关键概念我们在后续的课程中都会陆续展开今天这一讲咱们先来“知其然”“知其所以然”的部分咱们放到后面去讲。
对于SparkSession你可以把它理解为是SparkContext的进阶版是Spark2.0版本以后新一代的开发入口。SparkContext通过textFile API把源数据转换为RDD而SparkSession通过read API把源数据转换为DataFrame。
而DataFrame你可以把它看作是一种特殊的RDD。RDD我们已经很熟悉了现在就把DataFrame跟RDD做个对比让你先对DataFrame有个感性认识。
先从功能分析与RDD一样DataFrame也用来封装分布式数据集它也有数据分区的概念也是通过算子来实现不同DataFrame之间的转换只不过DataFrame采用了一套与RDD算子不同的独立算子集。
再者在数据内容方面与RDD不同DataFrame是一种带Schema的分布式数据集因此你可以简单地把DataFrame看作是数据库中的一张二维表。
最后DataFrame背后的计算引擎是Spark SQL而RDD的计算引擎是Spark Core这一点至关重要。不过关于计算引擎之间的差异我们留到[下一讲]再去展开。
好啦言归正传。简单了解了SparkSession与DataFrame的概念之后我们继续来看数据探索。
把上述代码丢进spark-shell之后分别在applyNumbersDF和luckyDogsDF这两个DataFrame之上调用show函数我们就可以得到样本数据。可以看到“这两张表”的Schema是一样的它们都包含两个字段一个是String类型的carNum另一个是类型为Int的batchNum。
其中carNum的含义是申请号码、或是中签号码而batchNum则代表摇号批次比如201906表示2019年的最后一批摇号201401表示2014年的第一次摇号。
好啦,进行到这里,初步的数据探索工作就告一段落了。
业务需求实现
完成初步的数据探索之后我们就可以结合数据特点比如两张表的Schema完全一致但数据内容的范畴不同来实现最开始的业务需求计算中签率与倍率之间的量化关系。
首先既然是要量化中签率与倍率之间的关系我们只需要关注那些中签者lucky目录下的数据的倍率变化就好了。而倍率的计算要依赖apply目录下的摇号数据。因此要做到仅关注中签者的倍率我们就必须要使用数据关联这个在数据分析领域中最常见的操作。此外由于倍率制度自2016年才开始推出所以我们只需要访问2016年以后的数据即可。
基于以上这些分析,我们先把数据过滤与数据关联的代码写出来,如下所示。
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
在上面的代码中我们使用filter算子对luckyDogsDF做过滤然后使用select算子提取carNum字段。
紧接着我们在applyNumbersDF之上调用join算子从而完成两个DataFrame的数据关联。join算子有3个参数你可以对照前面代码的第5行来理解这里第一个参数用于指定需要关联的DataFrame第二个参数代表Join Key也就是依据哪些字段做关联而第三个参数指定的是关联形式比如inner表示内关联left表示左关联等等。
做完数据关联之后,接下来,我们再来说一说,倍率应该怎么统计。对于倍率这个数值,官方的实现略显粗暴,如果去观察 apply 目录下 2016 年以后各个批次的文件,你就会发现,所谓的倍率,实际上就是申请号码的副本数量。
比如说我的倍率是8那么在各个批次的摇号文件中我的申请号码就会出现8次。是不是很粗暴因此要统计某个申请号码的倍率我们只需要统计它在批次文件中出现的次数就可以达到目的。
按照批次、申请号码做统计计数是不是有种熟悉的感觉没错这不就是我们之前学过的Word Count吗它本质上其实就是一个分组计数的过程。不过这一次咱们不再使用reduceByKey这个RDD算子了而是使用DataFrame的那套算子来实现我们先来看代码。
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
分组计数
对照代码我给你分析下思路我们先是用groupBy算子来按照摇号批次和申请号码做分组然后通过agg和count算子把batchNumcarNum出现的次数作为carNum在摇号批次batchNum中的倍率并使用alias算子把倍率重命名为“multiplier”。
这么说可能有点绕我们可以通过在multipliers之上调用show函数来直观地观察这一步的计算结果。为了方便说明我用表格的形式来进行示意。
可以看到,同一个申请号码,在不同批次中的倍率是不一样的。就像我们之前说的,随着摇号的次数增加,倍率也会跟着提升。不过,这里咱们要研究的是倍率与中签率的关系,所以只需要关心中签者是在多大的倍率下中签的就行。因此,对于同一个申请号码,我们只需要保留其中最大的倍率就可以了。
需要说明的是取最大倍率的做法会把倍率的统计基数变小从而引入幸存者偏差。更严谨的做法应该把中签者过往的倍率也都统计在内这样倍率的基数才是准确的。不过呢结合实验幸存者偏差并不影响“倍率与中签率是否有直接关系”这一结论。因此咱们不妨采用取最大倍率这种更加简便的做法。毕竟学习Spark SQL才是咱们的首要目标。
为此我们需要“抹去”batchNum这个维度按照carNum对multipliers做分组并提取倍率的最大值代码如下所示。
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
分组聚合的方法跟前面差不多我们还是先用groupBy做分组不过这次仅用carNum一个字段做分组然后使用agg和max算子来保留倍率最大值。经过这一步的计算之后我们就得到了每个申请号码在中签之前的倍率系数
可以看到uniqueMultipliers这个DataFrame仅包含申请号码carNum和倍率multiplier这两个字段且carNum字段不存在重复值也就是说在这份数据集中一个申请号码只有一个最大倍率与之对应。
好啦,到此为止,我们拿到了每一个中签者,在中签之前的倍率系数。接下来,结合这份数据,我们就可以统计倍率本身的分布情况。
具体来说我们想知道的是不同倍率之下的人数分布是什么样子的。换句话说这一次我们要按照倍率来对数据做分组然后计算不同倍率下的统计计数。不用说这次咱们还是得仰仗groupBy和agg这两个算子代码如下所示。
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
在最后一步我们依然使用groupBy和agg算子如法炮制得到按照倍率统计的人数分布之后我们通过collect算子来收集计算结果并同时触发上述的所有代码从头至尾交付执行。
计算结果result包含两个字段一个是倍率一个是持有该倍率的统计人数。如果把result结果数据做成柱状图的话我们可以更加直观地观察到中签率与倍率之间的关系如下图所示。
不难发现,不同倍率下的中签者人数,呈现出正态分布。也即是说,对于一个申请者来说,他/她有幸摇中的概率,并不会随着倍率的增加而线性增长。用身边那些“老司机”的话说,中签这件事,确实跟倍率的关系不大。
重点回顾
今天这一讲,我们一起动手,开发了“倍率的统计分布”这个数据分析应用,并解答了中签率与倍率之间是否存在关联关系这一难题。
尽管在实现的过程中我们遇到了一些新概念和新的算子但你不必担心更不必着急。今天这节课你只需要对Spark SQL框架下的应用开发有一个感性的认识就可以了。
在Spark SQL的开发框架下我们通常是通过SparkSession的read API从源数据创建DataFrame。然后以DataFrame为入口在DataFrame之上调用各式各样的转换算子如agg、groupBy、select、filter等等对DataFrame进行转换进而完成相应的数据分析。
为了后续试验方便我把今天涉及的代码片段整理到了一起你可以把它们丢进spark-shell去运行观察每个环节的计算结果体会不同算子的计算逻辑与执行结果之间的关系。加油祝你好运
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
每课一练
脑洞时间:你觉得汽车摇号的倍率制度应该怎样设计,才是最合理的?
请在你的Spark环境中把代码运行起来并确认执行结果是否与result一致。
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的朋友、同事。我们下一讲见!

View File

@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 台前幕后DataFrame与Spark SQL的由来
你好,我是吴磊。
在上一讲结合“小汽车倍率分析”的例子我们学习了在Spark SQL子框架下做应用开发的一般模式。我们先是使用SparkSession的read API来创建DataFrame然后以DataFrame为入口通过调用各式各样的算子来完成不同DataFrame之间的转换从而进行数据分析。
尽管我们说过你可以把DataFrame看作是一种特殊的RDD但你可能仍然困惑DataFrame到底跟RDD有什么本质区别。Spark已经有了RDD这个开发入口为什么还要重复造轮子整出个DataFrame来呢
相信学完了上一讲这些问题一定萦绕在你的脑海里挥之不去。别着急今天我们就来高屋建瓴地梳理一下DataFrame的来龙去脉然后再追本溯源看看帮助DataFrame崭露头角的幕后大佬Spark SQL又是怎么回事儿。
RDD之殇优化空间受限
在RDD算子那一讲[第3讲]我们曾经留过一道思考题像map、mapPartitions、filter、flatMap这些算子它们之间都有哪些共性
今天我们从一个全新的视角来重新审视这个问题。先说结论它们都是高阶函数Higher-order Functions
所谓高阶函数,它指的是形参为函数的函数,或是返回类型为函数的函数。换句话说,高阶函数,首先本质上也是函数,特殊的地方在于它的形参和返回类型,这两者之中只要有一个是函数类型,那么原函数就属于高阶函数。
上面提到的这些算子如map、filter它们都需要一个辅助函数f来作为形参通过调用map(f)、filter(f)才能完成计算。以map为例我们需要函数f来明确对哪些字段做映射以什么规则映射。filter也一样我们需要函数f来指明以什么条件在哪些字段上过滤。
但是这样一来Spark只知道开发者要做map、filter但并不知道开发者打算怎么做map和filter。换句话说对于Spark来说辅助函数f是透明的。在RDD的开发框架下Spark Core只知道开发者要“做什么”而不知道“怎么做”。这让Spark Core两眼一抹黑除了把函数f以闭包的形式打发到Executors以外实在是没有什么额外的优化空间。而这就是RDD之殇。
DataFrame横空出世
针对RDD优化空间受限的问题Spark社区在1.3版本发布了DataFrame。那么相比RDDDataFrame到底有何不同呢我们不妨从两个方面来对比它们的不同一个是数据的表示形式Data Representation另一个是开发算子。
DataFrame与RDD一样都是用来封装分布式数据集的。但在数据表示方面就不一样了DataFrame是携带数据模式Data Schema的结构化数据而RDD是不携带Schema的分布式数据集。恰恰是因为有了Schema提供明确的类型信息Spark才能耳聪目明有针对性地设计出更紧凑的数据结构从而大幅度提升数据存储与访问效率。
在开发API方面RDD算子多采用高阶函数高阶函数的优势在于表达能力强它允许开发者灵活地设计并实现业务逻辑。而DataFrame的表达能力却很弱它定义了一套DSL算子Domain Specific Language如我们上一节课用到的select、filter、agg、groupBy等等它们都属于DSL算子。
DSL语言往往是为了解决某一类特定任务而设计非图灵完备因此在表达能力方面非常有限。DataFrame的算子大多数都是标量函数Scalar Functions它们的形参往往是结构化二维表的数据列Columns
尽管DataFrame算子在表达能力方面更弱但是DataFrame每一个算子的计算逻辑都是确定的比如select用于提取某些字段groupBy用于对数据做分组等等。这些计算逻辑对Spark来说不再是透明的因此Spark可以基于启发式的规则或策略甚至是动态的运行时信息去优化DataFrame的计算过程。
总结下来相比RDDDataFrame通过携带明确类型信息的Schema、以及计算逻辑明确的转换算子为Spark引擎的内核优化打开了全新的空间。
幕后英雄Spark SQL
那么问题来了优化空间打开之后真正负责优化引擎内核Spark Core的那个幕后英雄是谁相信不用我说你也能猜到它就是Spark SQL。
想要吃透Spark SQL我们先得弄清楚它跟Spark Core的关系。随着学习进程的推进我们接触的新概念、知识点会越来越多厘清Spark SQL与Spark Core的关系有利于你构建系统化的知识体系和全局视角从而让你在学习的过程中“既见树木、也见森林”。
首先Spark Core特指Spark底层执行引擎Execution Engine它包括了我们在基础知识篇讲过的调度系统、存储系统、内存管理、Shuffle管理等核心功能模块。而Spark SQL则凌驾于Spark Core之上是一层独立的优化引擎Optimization Engine。换句话说Spark Core负责执行而Spark SQL负责优化Spark SQL优化过后的代码依然要交付Spark Core来做执行。
再者从开发入口来说在RDD框架下开发的应用程序会直接交付Spark Core运行。而使用DataFrame API开发的应用则会先过一遍Spark SQL由Spark SQL优化过后再交由Spark Core去做执行。
弄清二者的关系与定位之后接下来的问题是“基于DataFrameSpark SQL是如何进行优化的呢”要回答这个问题我们必须要从Spark SQL的两个核心组件说起Catalyst优化器和Tungsten。
先说Catalyst优化器它的职责在于创建并优化执行计划它包含3个功能模块分别是创建语法树并生成执行计划、逻辑阶段优化和物理阶段优化。Tungsten用于衔接Catalyst执行计划与底层的Spark Core执行引擎它主要负责优化数据结果与可执行代码。
接下来我们结合上一讲“倍率分析”的例子来说一说那段代码在Spark SQL这一层是如何被优化的。我把“倍率分析”完整的代码实现贴在了这里你不妨先简单回顾一下。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
Catalyst优化器
首先我们先来说说Catalyst的优化过程。基于代码中DataFrame之间确切的转换逻辑Catalyst会先使用第三方的SQL解析器ANTLR生成抽象语法树ASTAbstract Syntax Tree。AST由节点和边这两个基本元素构成其中节点就是各式各样的操作算子如select、filter、agg等而边则记录了数据表的Schema信息如字段名、字段类型等等。
以下图“倍率分析”的语法树为例它实际上描述了从源数据到最终计算结果之间的转换过程。因此在Spark SQL的范畴内AST语法树又叫作“执行计划”Execution Plan
可以看到由算子构成的语法树、或者说执行计划给出了明确的执行步骤。即使不经过任何优化Spark Core也能把这个“原始的”执行计划按部就班地运行起来。
不过从执行效率的角度出发这么做并不是最优的选择。为什么这么说呢我们以图中绿色的节点为例Scan用于全量扫描并读取中签者数据Filter则用来过滤出摇号批次大于等于“201601”的数据Select节点的作用则是抽取数据中的“carNum”字段。
还记得吗我们的源文件是以Parquet格式进行存储的而Parquet格式在文件层面支持“谓词下推”Predicates Pushdown和“列剪枝”Columns Pruning这两项特性。
谓词下推指的是利用像“batchNum >= 201601”这样的过滤条件在扫描文件的过程中只读取那些满足条件的数据文件。又因为Parquet格式属于列存Columns Store数据结构因此Spark只需读取字段名为“carNum”的数据文件而“剪掉”读取其他数据文件的过程。
以中签数据为例在谓词下推和列剪枝的帮助下Spark Core只需要扫描图中绿色的文件部分。显然这两项优化都可以有效帮助Spark Core大幅削减数据扫描量、降低磁盘I/O消耗从而显著提升数据的读取效率。
因此如果能把3个绿色节点的执行顺序从“Scan > Filter > Select”调整为“Filter > Select > Scan”那么相比原始的执行计划调整后的执行计划能给Spark Core带来更好的执行性能。
像谓词下推、列剪枝这样的特性都被称为启发式的规则或策略。而Catalyst优化器的核心职责之一就是在逻辑优化阶段基于启发式的规则和策略调整、优化执行计划为物理优化阶段提升性能奠定基础。经过逻辑阶段的优化之后原始的执行计划调整为下图所示的样子请注意绿色节点的顺序变化。
经过逻辑阶段优化的执行计划依然可以直接交付Spark Core去运行不过在性能优化方面Catalyst并未止步于此。
除了逻辑阶段的优化Catalyst在物理优化阶段还会进一步优化执行计划。与逻辑阶段主要依赖先验的启发式经验不同物理阶段的优化主要依赖各式各样的统计信息如数据表尺寸、是否启用数据缓存、Shuffle中间文件等等。换句话说逻辑优化更多的是一种“经验主义”而物理优化则是“用数据说话”。
以图中蓝色的Join节点为例执行计划仅交代了applyNumbersDF与filteredLuckyDogs这两张数据表需要做内关联但是它并没有交代清楚这两张表具体采用哪种机制来做关联。按照实现机制来分类数据关联有3种实现方式分别是嵌套循环连接NLJNested Loop Join、排序归并连接Sort Merge Join和哈希连接Hash Join
而按照数据分发方式来分类数据关联又可以分为Shuffle Join和Broadcast Join这两大类。因此在分布式计算环境中至少有6种Join策略供Spark SQL来选择。对于这6种Join策略我们以后再详细展开这里你只需要了解不同策略在执行效率上有着天壤之别即可。
回到蓝色Join节点的例子在物理优化阶段Catalyst优化器需要结合applyNumbersDF与filteredLuckyDogs这两张表的存储大小来决定是采用运行稳定但性能略差的Shuffle Sort Merge Join还是采用执行性能更佳的Broadcast Hash Join。
不论Catalyst决定采用哪种Join策略优化过后的执行计划都可以丢给Spark Core去做执行。不过Spark SQL优化引擎并没有就此打住当Catalyst优化器完成它的“历史使命”之后Tungsten会接过接力棒在Catalyst输出的执行计划之上继续打磨、精益求精力求把最优的执行代码交付给底层的SparkCore执行引擎。
Tungsten
站在Catalyst这个巨人的肩膀上Tungsten主要是在数据结构和执行代码这两个方面做进一步的优化。数据结构优化指的是Unsafe Row的设计与实现执行代码优化则指的是全阶段代码生成WSCGWhole Stage Code Generation
我们先来看看为什么要有Unsafe Row。对于DataFrame中的每一条数据记录Spark SQL默认采用org.apache.spark.sql.Row对象来进行封装和存储。我们知道使用Java Object来存储数据会引入大量额外的存储开销。
为此Tungsten设计并实现了一种叫做Unsafe Row的二进制数据结构。Unsafe Row本质上是字节数组它以极其紧凑的格式来存储DataFrame的每一条数据记录大幅削减存储开销从而提升数据的存储与访问效率。
以下表的Data Schema为例对于包含如下4个字段的每一条数据记录来说如果采用默认的Row对象进行存储的话那么每条记录需要消耗至少60个字节。
但如果用Tungsten Unsafe Row数据结构进行存储的话每条数据记录仅需消耗十几个字节如下图所示。
说完了Unsafe Row的数据结构优化接下来我们再来说说WSCG全阶段代码生成。所谓全阶段其实就是我们在调度系统中学过的Stage。以图中的执行计划为例标记为绿色的3个节点在任务调度的时候会被划分到同一个Stage。
而代码生成指的是Tungsten在运行时把算子之间的“链式调用”捏合为一份代码。以上图3个绿色的节点为例在默认情况下Spark Core会对每一条数据记录都依次执行Filter、Select和Scan这3个操作。
经过了Tungsten的WSCG优化之后Filter、Select和Scan这3个算子会被“捏合”为一个函数f。这样一来Spark Core只需要使用函数f来一次性地处理每一条数据就能消除不同算子之间数据通信的开销一气呵成地完成计算。
好啦到此为止分别完成Catalyst和Tungsten这两个优化环节之后Spark SQL终于“心满意足”地把优化过的执行计划、以及生成的执行代码交付给老大哥Spark Core。Spark Core拿到计划和代码在运行时利用Tungsten Unsafe Row的数据结构完成分布式任务计算。到此我们这一讲的内容也就讲完了。
重点回顾
今天这一讲涉及的内容很多,我们一起做个总结。
首先在RDD开发框架下Spark Core的优化空间受限。绝大多数RDD高阶算子所封装的封装的计算逻辑形参函数f对于Spark Core是透明的Spark Core除了用闭包的方式把函数f分发到Executors以外没什么优化余地。
而DataFrame的出现带来了新思路它携带的Schema提供了丰富的类型信息而且DataFrame算子大多为处理数据列的标量函数。DataFrame的这两个特点为引擎内核的优化打开了全新的空间。在DataFrame的开发框架下负责具体优化过程的正是Spark SQL。
Spark SQL则是凌驾于Spark Core之上的一层优化引擎它的主要职责是在用户代码交付Spark Core之前对用户代码进行优化。
Spark SQL由两个核心组件构成分别是Catalyst优化器和Tungsten其优化过程也分为Catalyst和Tungsten两个环节。
在Catalyst优化环节Spark SQL首先把用户代码转换为AST语法树又叫执行计划然后分别通过逻辑优化和物理优化来调整执行计划。逻辑阶段的优化主要通过先验的启发式经验如谓词下推、列剪枝对执行计划做优化调整。而物理阶段的优化更多是利用统计信息选择最佳的执行机制、或添加必要的计算节点。
Tungsten主要从数据结构和执行代码两个方面进一步优化。与默认的Java Object相比二进制的Unsafe Row以更加紧凑的方式来存储数据记录大幅提升了数据的存储与访问效率。全阶段代码生成消除了同一Stage内部不同算子之间的数据传递把多个算子融合为一个统一的函数并将这个函数一次性地作用Apply到数据之上相比不同算子的“链式调用”这会显著提升计算效率。
每课一练
学完这一讲之后我们知道只有DataFrame才能“享受”到Spark SQL的优化过程而RDD只能直接交付Spark Core执行。那么这是不是意味着RDD开发框架会退出历史舞台而我们之前学过的与RDD有关的知识点如RDD概念、RDD属性、RDD算子都白学了呢
欢迎你在留言区和我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View File

@ -0,0 +1,363 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 数据源与数据格式DataFrame从何而来
你好,我是吴磊。
在上一讲我们重点讲解了DataFrame与Spark SQL的渊源并提到DataFrame是Spark SQL的重要入口。换句话说通过创建DataFrame并沿用DataFrame开发API我们才能充分利用Spark SQL优化引擎提供种种“性能红利”。显然对于初学者来说第一步的创建DataFrame就变得至关重要。
之前 [第13讲]我们做小汽车摇号倍率分析时用了SparkSession的read API从Parquet文件创建DataFrame其实创建DataFrame的方法还有很多。毫不夸张地说DataFrame的创建途径异常丰富为什么这么说呢
如下图所示Spark支持多种数据源按照数据来源进行划分这些数据源可以分为如下几个大类Driver端自定义的数据结构、分布式文件系统、关系型数据库RDBMS、关系型数据仓库、NoSQL数据库以及其他的计算引擎。
显然要深入地介绍Spark与每一种数据源的集成并不现实也没必要咱们只需要把注意力放在那些最常用、最常见的集成方式即可。
这一讲我会从Driver、文件系统与RDBMS三个方面为你讲解5种常见的DataFrame创建方式然后带你了解不同方式的使用场景跟优劣分析。
从Driver创建DataFrame
在Driver端Spark可以直接从数组、元组、映射等数据结构创建DataFrame。使用这种方式创建的DataFrame通常数据量有限因此这样的DataFrame往往不直接参与分布式计算而是用于辅助计算或是数据探索。尽管如此学习这部分知识点还是非常必要的因为它可以帮我们更直观地理解DataFrame与RDD的关系。
还记得吗在数据表示Data Representation相比RDDDataFrame仅仅是多了一个Schema。甚至可以说DataFrame就是带Schema的RDD。因此创建DataFrame的第一种方法就是先创建RDD然后再给它“扣上”一顶Schema的“帽子”。
从本地数据结构创建RDD我们用的是SparkContext的parallelize方法而给RDD“扣帽子”我们要用到SparkSession的createDataFrame方法。
createDataFrame方法
为了创建RDD我们先来定义列表数据seq。seq的每个元素都是二元元组元组第一个元素的类型是String第二个元素的类型是Int。有了列表数据结构接下来我们创建RDD如下所示。
import org.apache.spark.rdd.RDD
val seq: Seq[(String, Int)] = Seq(("Bob", 14), ("Alice", 18))
val rdd: RDD[(String, Int)] = sc.parallelize(seq)
有了RDD之后我们来给它制作一顶“帽子”也就是我们刚刚说的Schema。创建Schema我们需要用到Spark SQL内置的几种类型如StructType、StructField、StringType、IntegerType等等。
其中StructType用于定义并封装SchemaStructFiled用于定义Schema中的每一个字段包括字段名、字段类型而像StringType、IntegerType这些*Type类型表示的正是字段类型。为了和RDD数据类型保持一致Schema对应的元素类型应该是StringTypeIntegerType
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
好啦到此为止我们有了RDD也有了为它量身定做的“帽子”Schema。不过在把帽子扣上去之前我们还要先给RDD整理下“发型”。这是什么意思呢
createDataFrame方法有两个形参第一个参数正是RDD第二个参数是Schema。createDataFrame要求RDD的类型必须是RDD[Row]其中的Row是org.apache.spark.sql.Row因此对于类型为RDD[(String, Int)]的rdd我们需要把它转换为RDD[Row]。
import org.apache.spark.sql.Row
val rowRDD: RDD[Row] = rdd.map(fileds => Row(fileds._1, fileds._2))
“发型”整理好之后我们就可以调用createDataFrame来创建DataFrame代码如下所示。
import org.apache.spark.sql.DataFrame
val dataFrame: DataFrame = spark.createDataFrame(rowRDD,schema)
DataFrame创建好之后别忘了验证它的可用性我们可以通过调用show方法来做简单的数据探索验证DataFrame创建是否成功。
dataFrame.show
/** 结果显示
+----+---+
| name| age|
+----+---+
| Bob| 14|
| Alice| 18|
+----+---+
*/
历尽千辛万苦我们先是用Driver端数据结构创建RDD然后再调用createDataFrame把RDD转化为DataFrame。你可能会说“相比用parallelize创建RDD用createDataFrame创建DataFrame的方法未免复杂了些有没有更简便的方法呢”我们接着往下看。
toDF方法
其实要把RDD转化为DataFrame我们并不一定非要亲自制作Schema这顶帽子还可以直接在RDD之后调用toDF方法来做到这一点。咱们先来看toDF函数的用法然后再去分析spark.implicits是如何帮我们轻松创建DataFrame的。
import spark.implicits._
val dataFrame: DataFrame = rdd.toDF
dataFrame.printSchema
/** Schema显示
root
|-- _1: string (nullable = true)
|-- _2: integer (nullable = false)
*/
可以看到我们显示导入了spark.implicits包中的所有方法然后通过在RDD之上调用toDF就能轻松创建DataFrame。实际上利用spark.implicits我们甚至可以跳过创建RDD这一步直接通过seq列表来创建DataFrame。
import spark.implicits._
val dataFrame: DataFrame = seq.toDF
dataFrame.printSchema
/** Schema显示
root
|-- _1: string (nullable = true)
|-- _2: integer (nullable = false)
*/
是不是感觉这个方法很简洁、很轻松不过你可能会问“既然有toDF这条捷径一开始干嘛还要花功夫去学步骤繁琐的createDataFrame方法呢
网络上流行过这么一句话“你的岁月静好是有人在背后帮你负重前行。”toDF也是同样的道理我们之所以能用toDF轻松创建DataFrame关键在于spark.implicits这个包提供了各种隐式方法。
隐式方法是Scala语言中一类特殊的函数这类函数不需要开发者显示调用函数体中的计算逻辑在适当的时候会自动触发。正是它们在背后默默地帮我们用seq创建出RDD再用createDataFrame方法把RDD转化为DataFrame。
从文件系统创建DataFrame
说完第一类数据源接下来我们再来看看Spark如何从文件系统创建DataFrame。
Spark支持多种文件系统常见的有HDFS、Amazon S3、本地文件系统等等。不过无论哪种文件系统Spark都要通过SparkSession的read API来读取数据并创建DataFrame。所以接下来我们需要先弄明白read API要怎样使用都有哪些注意事项。
read API由SparkSession提供它允许开发者以统一的形式来创建DataFrame如下图所示。
可以看到要使用read API创建DataFrame开发者只需要调用SparkSession的read方法同时提供3类参数即可。这3类参数分别是文件格式、加载选项和文件路径它们分别由函数format、option和load来指定。
先来看第1类参数文件格式它就是文件的存储格式如CSVComma Separated Values、Text、Parquet、ORC、JSON。Spark SQL支持种类丰富的文件格式除了这里列出的几个例子外Spark SQL还支持像Zip压缩文件、甚至是图片Image格式。
完整的格式支持你可以参考下图或是访问官网给出的列表。在后续的讲解中我们还会挑选一些常用的数据格式来演示read API的具体用法。
文件格式决定了第2类参数加载选项的可选集合也就是说不同的数据格式可用的选型有所不同。比如CSV文件格式可以通过option(“header”, true)来表明CSV文件的首行为Data Schema但其他文件格式就没有这个选型。之后讲到常见文件格式用法时我们再对其加载选项做具体讲解。
值得一提的是加载选项可以有零个或是多个当需要指定多个选项时我们可以用“option(选项1, 值1).option(选项2, 值2)”的方式来实现。
read API的第3类参数是文件路径这个参数很好理解它就是文件系统上的文件定位符。比如本地文件系统中的“/dataSources/wikiOfSpark.txt”HDFS分布式文件系统中的“hdfs://hostname:port/myFiles/userProfiles.csv”或是Amazon S3上的“s3://myBucket/myProject/myFiles/results.parquet”等等。
了解了read API的一般用法之后接下来我们结合一些常见的数据格式来进行举例说明。对于那些在这节课没有展开介绍的文件格式你可以参考官网给出的用法来做开发。
从CSV创建DataFrame
以可读性好的纯文本方式来存储结构化数据CSV文件格式的身影常见于数据探索、数据分析、机器学习等应用场景。经过上面的分析我们知道要从CSV文件成功地创建DataFrame关键在于了解并熟悉与之有关的加载选项。那么我们就来看看CSV格式都有哪些对应的option它们的含义都是什么。
从上往下看首先是“header”header的设置值为布尔值也即true或false它用于指定CSV文件的首行是否为列名。如果是的话那么Spark SQL将使用首行的列名来创建DataFrame否则使用“_c”加序号的方式来命名每一个数据列比如“_c0”、“_c1”等等。
对于加载的每一列数据不论数据列本身的含义是什么Spark SQL都会将其视为String类型。例如对于后面这个CSV文件Spark SQL将“name”和“age”两个字段都视为String类型。
name,age
alice,18
bob,14
import org.apache.spark.sql.DataFrame
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv").option("header", true).load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: string]
df.show
/** 结果打印
+-----+---+
| name| age|
+-----+---+
| alice| 18|
| bob| 14|
+-----+---+
*/
要想在加载的过程中为DataFrame的每一列指定数据类型我们需要显式地定义Data Schema并在read API中通过调用schema方法来将Schema传递给Spark SQL。Data Schema的定义我们讲createDataFrame函数的时候提过咱们不妨一起来回顾一下。
定义Schema
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
调用schema方法来传递Data Schema
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv").schema(schema).option("header", true).load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
可以看到在使用schema方法明确了Data Schema以后数据加载完成之后创建的DataFrame类型由原来的“[name: string, age: string]”,变为“[name: string, age: int]”。需要注意的是并不是所有文件格式都需要schema方法来指定Data Schema因此在read API的一般用法中schema方法并不是必需环节。
我们接着说CSV格式的option选项。在“header”之后第二个选项是“seq”它是用于分隔列数据的分隔符可以是任意字符串默认值是逗号。常见的分隔符还有Tab、“|”,等等。
之后的“escape”和“nullValue”分别用于指定文件中的转义字符和空值而“dateFormat”则用于指定日期格式它的设置值是任意可以转换为Java SimpleDateFormat类型的字符串默认值是“yyyy-MM-dd”。
最后一个选项是“mode”它用来指定文件的读取模式更准确地说它明确了Spark SQL应该如何对待CSV文件中的“脏数据”。
所谓脏数据它指的是数据值与预期数据类型不符的数据记录。比如说CSV文件中有一列名为“age”数据它用于记录用户年龄数据类型为整型Int。那么显然age列数据不能出现像“8.5”这样的小数、或是像“8岁”这样的字符串这里的“8.5”或是“8岁”就是我们常说的脏数据。
在不调用schema方法来显示指定Data Schema的情况下Spark SQL将所有数据列都看作是String类型。我们不难发现mode选项的使用往往会与schema方法的调用如影随形。
mode支持3个取值分别是permissive、dropMalformed和failFast它们的含义如下表所示。
可以看到除了“failFast”模式以外另外两个模式都不影响DataFrame的创建。以下面的CSV文件为例要想剔除脏数据也就是“cassie, six”这条记录同时正常加载满足类型要求的“干净”数据我们需要同时结合schema方法与mode选项来实现。
CSV文件内容
name,age
alice,18
bob,14
cassie, six
调用schema方法来传递Data Schema
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv")
.schema(schema)
.option("header", true)
.option("mode", "dropMalformed")
.load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
df.show
/** 结果打印
+-----+---+
| name| age|
+-----+---+
| alice| 18|
| bob| 14|
+-----+---+
*/
好啦关于从CSV文件创建DataFrame我们就讲完了。不难发现从CSV创建DataFrame过程相对比较繁琐开发者需要注意的细节也很多。不过毕竟CSV简单直接、跨平台、可读性好、应用广泛因此掌握这部分开发技巧还是非常值得的。
从Parquet / ORC创建DataFrame
接下来我们就来说说Parquet格式和ORC格式相比从CSV创建DataFrame这两个方法就没那么麻烦了。
Parquet与ORC都是应用广泛的列存Column-based Store文件格式。顾名思义列存是相对行存Row-based Store而言的。
在传统的行存文件格式中,数据记录以行为单位进行存储。虽然这非常符合人类的直觉,但在数据的检索与扫描方面,行存数据往往效率低下。例如,在数据探索、数据分析等数仓应用场景中,我们往往仅需扫描数据记录的某些字段,但在行存模式下,我们必须要扫描全量数据,才能完成字段的过滤。
CSV就是典型的行存数据格式以如下的内容为例如果我们想要统计文件中女生的数量那么我们不得不扫描每一行数据判断gender的取值然后才能决定是否让当前记录参与计数。
CSV文件内容
name,age,gender
alice,18,female
bob,14,male
列存文件则不同,它以列为单位,对数据进行存储,每一列都有单独的文件或是文件块。还是以上面的文件内容为例,如果采用列存格式的话,那么文件的存储方式将会变成下面的样子。
可以看到数据按列存储想要统计女生的数量我们只需扫描gender列的数据文件而不必扫描name与age字段的数据文件。相比行存列存有利于大幅削减数据扫描所需的文件数量。
不仅如此对于每一个列存文件或是文件块列存格式往往会附加header和footer等数据结构来记录列数据的统计信息比如最大值、最小值、记录统计个数等等。这些统计信息会进一步帮助提升数据访问效率例如对于max=“male”同时min=“male”的gender文件来说在统计女生计数的时候我们完全可以把这样的文件跳过不进行扫描。
再者很多列存格式往往在文件中记录Data Schema比如Parquet和ORC它们会利用Meta Data数据结构来记录所存储数据的数据模式。这样一来在读取类似列存文件时我们无需再像读取CSV一样去手工指定Data Schema这些繁琐的步骤都可以省去。因此使用read API来读取Parquet或是ORC文件就会变得非常轻松如下所示。
使用read API读取Parquet文件
val parquetFilePath: String = _
val df: DataFrame = spark.read.format("parquet").load(parquetFilePath)
使用read API读取ORC文件
val orcFilePath: String = _
val df: DataFrame = spark.read.format("orc").load(orcFilePath)
可以看到在read API的用法中我们甚至不需要指定任何option只要有format和load这两个必需环节即可。是不是非常简单
好啦到此为止我们梳理了如何从文件系统在不同的数据格式下创建DataFrame。在这一讲的最后我们再来简单地了解一下如何从关系型数据库创建DataFrame毕竟这个场景在我们日常的开发中还是蛮常见的。
从RDBMS创建DataFrame
使用read API读取数据库就像是使用命令行连接数据库那么简单。而使用命令行连接数据库我们往往需要通过参数来指定数据库驱动、数据库地址、用户名、密码等关键信息。read API也是一样只不过这些参数通通由option选项来指定以MySQL为例read API的使用方法如下。
使用read API连接数据库并创建DataFrame
spark.read.format("jdbc")
.option("driver", "com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://hostname:port/mysql")
.option("user", "用户名")
.option("password","密码")
.option("numPartitions", 20)
.option("dbtable", "数据表名 ")
.load()
访问数据库我们同样需要format方法来指定“数据源格式”这里的关键字是“jdbc”。请注意由于数据库URL通过option来指定因此调用load方法不再需要传入“文件路径”我们重点来关注option选项的设置。
与命令行一样option选项同样需要driver、url、user、password这些参数来指定数据库连接的常规设置。不过毕竟调用read API的目的是创建DataFrame因此我们还需要指定“dbtable”选项来确定要访问哪个数据表。
有意思的是除了将表名赋值给“dbtable”以外我们还可以把任意的SQL查询语句赋值给该选项这样在数据加载的过程中就能完成数据过滤提升访问效率。例如我们想从users表选出所有的女生数据然后在其上创建DataFrame。
val sqlQuery: String = “select * from users where gender = female
spark.read.format("jdbc")
.option("driver", "com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://hostname:port/mysql")
.option("user", "用户名")
.option("password","密码")
.option("numPartitions", 20)
.option("dbtable", sqlQuery)
.load()
此外为了提升后续的并行处理效率我们还可以通过“numPartitions”选项来控制DataFrame的并行度也即DataFrame的Partitions数量。
需要额外注意的是在默认情况下Spark安装目录并没有提供与数据库连接有关的任何Jar包因此对于想要访问的数据库不论是MySQL、PostgreSQL还是Oracle、DB2我们都需要把相关Jar包手工拷贝到Spark安装目录下的Jars文件夹。与此同时我们还要在spark-shell命令或是spark-submit中通过如下两个命令行参数来告诉Spark相关Jar包的访问地址。
driver-class-path mysql-connector-java-version.jar
jars mysql-connector-java-version.jar
好啦,到此为止,这一讲的内容就全部讲完啦!今天的内容有点多,我们来一起总结一下。
重点回顾
今天这一讲我们聚焦在DataFrame的创建方式上。Spark支持种类丰富的数据源与数据格式我们今天的重点是通过Driver、文件系统和关系型数据库来创建DataFrame。
在Driver端我们可以使用createDataFrame方法来创建DataFrame需要注意的是这种创建方式有两个前提条件。一是底层RDD的类型必须是RDD[Row]二是我们需要手工创建Data Schema。Schema的创建需要用到StructType、StructField等数据类型你要牢记在心。
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
除了这种比较繁琐的方式之外我们还可以利用spark.implicits._提供的隐式方法通过在RDD或是原始序列数据之上调用toDF方法轻松创建DataFrame。
接着使用SparkSession的read API我们分别讲解了从CSV、Parquet、ORC和关系型数据库创建DataFrame的一般方法。read API调用的一般方法需要你熟练掌握。
由于Parquet、ORC这类列存格式在文件中内置了Data Schema因此访问这类文件格式只有format和load两个方法是必需的。
相比之下读取CSV较为复杂。首先为了指定Data Schema开发者需要额外通过schema方法来输入预定义的数据模式。再者CSV的option选项比较多你可以参考后面的表格来更好地控制CSV数据的加载过程。
最后我们学习了read API访问RDBMS的一般方法。与命令行的访问方式类似你需要通过多个option选项来指定数据库连接所必需的访问参数如数据库驱动、URL地址、用户名、密码等等。特别地你还可以为“dbtable”选项指定表名或是查询语句对数据的加载过程进行干预和控制。
每课一练
给定如下CSV文件请你分别使用permissive, dropMalformed, failFast这3种mode对比read API所创建的DataFrame之间的区别。
name,age
alice,18
bob,14
cassie, six
欢迎你在留言区跟我交流活动,也推荐你把这一讲的内容分享给更多的同事、朋友,跟他一起学习进步。

View File

@ -0,0 +1,428 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 数据转换如何在DataFrame之上做数据处理
你好,我是吴磊。
在上一讲我们学习了创建DataFrame的各种途径与方法那么有了DataFrame之后我们该如何在DataFrame之上做数据探索、数据分析以及各式各样的数据转换呢在数据处理完毕之后我们又该如何做数据展示与数据持久化呢今天这一讲我们就来解答这些疑问。
为了给开发者提供足够的灵活性对于DataFrame之上的数据处理Spark SQL支持两类开发入口一个是大家所熟知的结构化查询语言SQL另一类是DataFrame开发算子。就开发效率与执行效率来说二者并无优劣之分选择哪种开发入口完全取决于开发者的个人偏好与开发习惯。
与RDD类似DataFrame支持种类繁多的开发算子但相比SQL语言DataFrame算子的学习成本相对要高一些。因此本着先易后难的思路咱们先来说说DataFrame中SQL语句的用法然后再去理解DataFrame开发算子。
SQL语句
对于任意的DataFrame我们都可以使用createTempView或是createGlobalTempView在Spark SQL中创建临时数据表。
两者的区别在于createTempView创建的临时表其生命周期仅限于SparkSession内部而createGlobalTempView创建的临时表可以在同一个应用程序中跨SparkSession提供访问。有了临时表之后我们就可以使用SQL语句灵活地倒腾表数据。
通过后面这段代码我为你演示了如何使用createTempView创建临时表。我们首先用toDF创建了一个包含名字和年龄的DataFrame然后调用createTempView方法创建了临时表。
import org.apache.spark.sql.DataFrame
import spark.implicits._
val seq = Seq(("Alice", 18), ("Bob", 14))
val df = seq.toDF("name", "age")
df.createTempView("t1")
val query: String = "select * from t1"
// spark为SparkSession实例对象
val result: DataFrame = spark.sql(query)
result.show
/** 结果打印
+-----+---+
| n ame| age|
+-----+---+
| Alice| 18|
| Bob| 14|
+-----+---+
*/
以上表为例我们先是使用spark.implicits._隐式方法通过toDF来创建DataFrame然后在其上调用createTempView来创建临时表“t1”。接下来给定SQL查询语句“query”我们可以通过调用SparkSession提供的sql API来提请执行查询语句得到的查询结果被封装为新的DataFrame。
值得一提的是与RDD的开发模式一样DataFrame之间的转换也属于延迟计算当且仅当出现Action类算子时如上表中的show所有之前的转换过程才会交付执行。
Spark SQL采用ANTLR语法解析器来解析并处理SQL语句。我们知道ANTLR是一款强大的、跨语言的语法解析器因为它全面支持SQL语法所以广泛应用于Oracle、Presto、Hive、ElasticSearch等分布式数据仓库和计算引擎。因此像Hive或是Presto中的SQL查询语句都可以平滑地迁移到Spark SQL。
不仅如此Spark SQL还提供大量Built-in Functions内置函数用于辅助数据处理如array_distinct、collect_list等等。你可以浏览官网的Built-in Functions页面查找完整的函数列表。结合SQL语句以及这些灵活的内置函数你就能游刃有余地应对数据探索、数据分析这些典型的数据应用场景。
SQL语句相对比较简单学习路径短、成本低你只要搞清楚如何把DataFrame转化为数据表剩下的事就水到渠成了。接下来我们把主要精力放在DataFrame支持的各类算子上这些算子提供的功能往往能大幅提升开发效率让我们事半功倍。
DataFrame算子
不得不说DataFrame支持的算子丰富而又全面这主要源于DataFrame特有的“双面”属性。一方面DataFrame来自RDD与RDD具有同源性因此RDD支持的大部分算子DataFrame都支持。另一方面DataFrame携带Schema是结构化数据因此它必定要提供一套与结构化查询同源的计算算子。
正是由于这样“双面”的特性我们从下图可以看到DataFrame所支持的算子用“琳琅满目”来形容都不为过。
人类的大脑偏好结构化的知识为了方便你记忆与理解我把DataFrame上述两个方面的算子进一步划分为6大类它们分别是RDD同源类算子、探索类算子、清洗类算子、转换类算子、分析类算子和持久化算子。
你可能会困扰“天呐这么多算子要学这不是逼我从入门到放弃吗”别着急上面这张图你可以把它当作是“DataFrame算子脑图”或是一本字典。在日常的开发中思路枯竭的时候你不妨把它翻出来看看哪些算子能够帮你实现业务逻辑。
今天这一讲,我们也会根据这张“脑图”,重点讲解其中最常用、最关键的部分。
同源类算子
我们从DataFrame中的RDD同源类算子说起这些算子在RDD算子那三讲做过详细的介绍如果你对有哪个算子的作用或含义记不清了不妨回看之前的三讲。我按照之前的分类把这些算子整理成了一张表格。
探索类算子
接下来就是DataFrame的探索类算子。所谓探索指的是数据探索这类算子的作用在于帮助开发者初步了解并认识数据比如数据的模式Schema、数据的分布、数据的“模样”等等为后续的应用开发奠定基础。
对于常用的探索类算子,我把它们整理到了下面的表格中,你不妨先看一看,建立“第一印象”。
我们来依次“避轻就重”地说一说这些算子。首先columns/schema/printSchema这3个算子类似都可以帮我们获取DataFrame的数据列和Schema。尤其是printSchema它以纯文本的方式将Data Schema打印到屏幕上如下所示。
import org.apache.spark.sql.DataFrame
import spark.implicits._
val employees = Seq((1, "John", 26, "Male"), (2, "Lily", 28, "Female"), (3, "Raymond", 30, "Male"))
val employeesDF: DataFrame = employees.toDF("id", "name", "age", "gender")
employeesDF.printSchema
/** 结果打印
root
|-- id: integer (nullable = false)
|-- name: string (nullable = true)
|-- age: integer (nullable = false)
|-- gender: string (nullable = true)
*/
了解数据模式之后我们往往想知道数据具体长什么样子对于这个诉求show算子可以帮忙达成。在默认情况下show会随机打印出DataFrame的20条数据记录。
employeesDF.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| John| 26| Male|
| 2| Lily| 28|Female|
| 3|Raymond| 30| Male|
+---+-------+---+------+
*/
看清了数据的“本来面目”之后你还可以进一步利用describe去查看数值列的统计分布。比如通过调用employeesDF.describe(“age”)你可以查看age列的极值、平均值、方差等统计数值。
初步掌握了数据的基本情况之后如果你对当前DataFrame的执行计划感兴趣可以通过调用explain算子来获得Spark SQL给出的执行计划。explain对于执行效率的调优来说有着至关重要的作用后续课程中我们还会结合具体的实例来深入讲解explain的用法和释义在这里你仅需知道explain是用来查看执行计划的就好。
清洗类算子
完成数据探索以后我们正式进入数据应用的开发阶段。在数据处理前期我们往往需要对数据进行适当地“清洗”“洗掉”那些不符合业务逻辑的“脏数据”。DataFrame提供了如下算子来帮我们完成这些脏活儿、累活儿。
首先drop算子允许开发者直接把指定列从DataFrame中予以清除。举个例子对于上述的employeesDF假设我们想把性别列清除那么直接调用 employeesDF.drop(“gender”) 即可。如果要同时清除多列只需要在drop算子中用逗号把多个列名隔开即可。
第二个是distinct它用来为DataFrame中的数据做去重。还是以employeesDF为例当有多条数据记录的所有字段值都相同时使用distinct可以仅保留其中的一条数据记录。
接下来是dropDuplicates它的作用也是去重。不过与distinct不同的是dropDuplicates可以指定数据列因此在灵活性上更胜一筹。还是拿employeesDF来举例这个DataFrame原本有3条数据记录如果我们按照性别列去重最后只会留下两条记录。其中一条记录的gender列是“Male”另一条的gender列为“Female”如下所示。
employeesDF.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| John| 26| Male|
| 2| Lily| 28|Female|
| 3|Raymond| 30| Male|
+---+-------+---+------+
*/
employeesDF.dropDuplicates("gender").show
/** 结果打印
+---+----+---+------+
| id|name|age|gender|
+---+----+---+------+
| 2|Lily| 28|Female|
| 1|John| 26| Male|
+---+----+---+------+
*/
表格中的最后一个算子是na它的作用是选取DataFrame中的null数据na往往要结合drop或是fill来使用。例如employeesDF.na.drop用于删除DataFrame中带null值的数据记录而employeesDF.na.fill(0) 则将DataFrame中所有的null值都自动填充为整数零。这两种用例在数据清洗的场景中都非常常见因此你需要牢牢掌握na.drop与na.fill的用法。
数据清洗过后,我们就得到了一份“整洁而又干净”的数据,接下来,可以放心大胆地去做各式各样的数据转换,从而实现业务逻辑需求。
转换类算子
转换类算子的主要用于数据的生成、提取与转换。转换类的算子的数量并不多,但使用方式非常灵活,开发者可以变着花样地变换数据。
首先select算子让我们可以按照列名对DataFrame做投影比如说如果我们只关心年龄与性别这两个字段的话就可以使用下面的语句来实现。
employeesDF.select("name", "gender").show
/** 结果打印
+-------+------+
| name|gender|
+-------+------+
| John| Male|
| Lily|Female|
|Raymond| Male|
+-------+------+
*/
不过虽然用起来比较简单但select算子在功能方面不够灵活。在灵活性这方面selectExpr做得更好。比如说基于id和姓名我们想把它们拼接起来生成一列新的数据。像这种需求正是selectExpr算子的用武之地。
employeesDF.selectExpr("id", "name", "concat(id, '_', name) as id_name").show
/** 结果打印
+---+-------+---------+
| id| name| id_name|
+---+-------+---------+
| 1| John| 1_John|
| 2| Lily| 2_Lily|
| 3|Raymond|3_Raymond|
+---+-------+---------+
*/
这里我们使用concat这个函数把id列和name列拼接在一起生成新的id_name数据列。
接下来的where和withColumnRenamed这两个算子比较简单where使用SQL语句对DataFrame做数据过滤而withColumnRenamed的作用是字段重命名。
比如想要过滤出所有性别为男的员工我们就可以用employeesDF.where(“gender = Male”)来实现。如果打算把employeesDF当中的“gender”重命名为“sex”就可以用withColumnRenamed来帮忙employeesDF.withColumnRenamed(“gender”, “sex”)。
紧接着的是withColumn虽然名字看上去和withColumnRenamed很像但二者在功能上有着天壤之别。
withColumnRenamed是重命名现有的数据列而withColumn则用于生成新的数据列这一点上withColumn倒是和selectExpr有着异曲同工之妙。withColumn也可以充分利用Spark SQL提供的Built-in Functions来灵活地生成数据。
比如,基于年龄列,我们想生成一列脱敏数据,隐去真实年龄,你就可以这样操作。
employeesDF.withColumn("crypto", hash($"age")).show
/** 结果打印
+---+-------+---+------+-----------+
| id| name|age|gender| crypto|
+---+-------+---+------+-----------+
| 1| John| 26| Male|-1223696181|
| 2| Lily| 28|Female|-1721654386|
| 3|Raymond| 30| Male| 1796998381|
+---+-------+---+------+-----------+
*/
可以看到我们使用内置函数hash生成一列名为“crypto”的新数据数据值是对应年龄的哈希值。有了新的数据列之后我们就可以调用刚刚讲的drop把原始的age字段丢弃掉。
表格中的最后一个算子是explode这个算子很有意思它的作用是展开数组类型的数据列数组当中的每一个元素都会生成一行新的数据记录。为了更好地演示explode的用法与效果我们把employeesDF数据集做个简单的调整给它加上一个interests兴趣字段。
val seq = Seq( (1, "John", 26, "Male", Seq("Sports", "News")),
(2, "Lily", 28, "Female", Seq("Shopping", "Reading")),
(3, "Raymond", 30, "Male", Seq("Sports", "Reading"))
)
val employeesDF: DataFrame = seq.toDF("id", "name", "age", "gender", "interests")
employeesDF.show
/** 结果打印
+---+-------+---+------+-------------------+
| id| name|age|gender| interests|
+---+-------+---+------+-------------------+
| 1| John| 26| Male| [Sports, News]|
| 2| Lily| 28|Female|[Shopping, Reading]|
| 3|Raymond| 30| Male| [Sports, Reading]|
+---+-------+---+------+-------------------+
*/
employeesDF.withColumn("interest", explode($"interests")).show
/** 结果打印
+---+-------+---+------+-------------------+--------+
| id| name|age|gender| interests|interest|
+---+-------+---+------+-------------------+--------+
| 1| John| 26| Male| [Sports, News]| Sports|
| 1| John| 26| Male| [Sports, News]| News|
| 2| Lily| 28|Female|[Shopping, Reading]|Shopping|
| 2| Lily| 28|Female|[Shopping, Reading]| Reading|
| 3|Raymond| 30| Male| [Sports, Reading]| Sports|
| 3|Raymond| 30| Male| [Sports, Reading]| Reading|
+---+-------+---+------+-------------------+--------+
*/
可以看到,我们多加了一个兴趣列,列数据的类型是数组,每个员工都有零到多个兴趣。
如果我们想把数组元素展开让每个兴趣都可以独占一条数据记录。这个时候就可以使用explode再结合withColumn生成一列新的interest数据。这列数据的类型是单个元素的String而不再是数组。有了新的interest数据列之后我们可以再次利用drop算子把原本的interests列抛弃掉。
数据转换完毕之后我们就可以通过数据的关联、分组、聚合、排序去做数据分析从不同的视角出发去洞察数据。这个时候我们还要依赖Spark SQL提供的多个分析类算子。
分析类算子
毫不夸张地说,前面的探索、清洗、转换,都是在为数据分析做准备。在大多数的数据应用中,数据分析往往是最为关键的那环,甚至是应用本身的核心目的。因此,熟练掌握分析类算子,有利于我们提升开发效率。
Spark SQL的分析类算子看上去并不多但灵活组合使用就会有“千变万化”的效果让我们一起看看。
为了演示上述算子的用法我们先来准备两张数据表employees和salaries也即员工信息表和薪水表。我们的想法是通过对两张表做数据关联来分析员工薪水的分布情况。
import spark.implicits._
import org.apache.spark.sql.DataFrame
// 创建员工信息表
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"))
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
// 创建薪水表
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
val salaries:DataFrame = seq2.toDF("id", "salary")
employees.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| Mike| 28| Male|
| 2| Lily| 30|Female|
| 3|Raymond| 26| Male|
+---+-------+---+------+
*/
salaries.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
那么首先我们先用join算子把两张表关联起来关联键Join Keys我们使用两张表共有的id列而关联形式Join Type自然是内关联Inner Join
val jointDF: DataFrame = salaries.join(employees, Seq("id"), "inner")
jointDF.show
/** 结果打印
+---+------+-------+---+------+
| id|salary| name|age|gender|
+---+------+-------+---+------+
| 1| 26000| Mike| 28| Male|
| 2| 30000| Lily| 30|Female|
| 3| 20000|Raymond| 26| Male|
+---+------+-------+---+------+
*/
可以看到我们在salaries之上调用join算子join算子的参数有3类。第一类是待关联的数据表在我们的例子中就是员工表employees。第二类是关联键也就是两张表之间依据哪些字段做关联我们这里是id列。第三类是关联形式我们知道关联形式有inner、left、right、anti、semi等等这些关联形式我们下一讲再展开这里你只需要知道Spark SQL支持这些种类丰富的关联形式即可。
数据完成关联之后,我们实际得到的仅仅是最细粒度的事实数据,也就是每个员工每个月领多少薪水。这样的事实数据本身并没有多少价值,我们往往需要从不同的维度出发,对数据做分组、聚合,才能获得更深入、更有价值的数据洞察。
比方说,我们想以性别为维度,统计不同性别下的总薪水和平均薪水,借此分析薪水与性别之间可能存在的关联关系。
val aggResult = fullInfo.groupBy("gender").agg(sum("salary").as("sum_salary"), avg("salary").as("avg_salary"))
aggResult.show
/** 数据打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
|Female| 30000| 30000.0|
| Male| 46000| 23000.0|
+------+----------+----------+
*/
这里我们先是使用groupBy算子按照“gender”列做分组然后使用agg算子做聚合运算。在agg算子中我们分别使用sum和avg聚合函数来计算薪水的总数和平均值。Spark SQL对于聚合函数的支持我们同样可以通过Built-in Functions页面来进行检索。结合Built-in Functions提供的聚合函数我们就可以灵活地对数据做统计分析。
得到统计结果之后为了方便查看我们还可以使用sort或是orderBy算子对结果集进行排序二者在用法与效果上是完全一致的如下表所示。
aggResult.sort(desc("sum_salary"), asc("gender")).show
/** 结果打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
| Male| 46000| 23000.0|
|Female| 30000| 30000.0|
+------+----------+----------+
*/
aggResult.orderBy(desc("sum_salary"), asc("gender")).show
/** 结果打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
| Male| 46000| 23000.0|
|Female| 30000| 30000.0|
+------+----------+----------+
*/
可以看到sort / orderBy支持按照多列进行排序且可以通过desc和asc来指定排序方向。其中desc表示降序排序相应地asc表示升序排序。
好啦到此为止我们沿着数据的生命周期分别梳理了生命周期不同阶段的Spark SQL算子它们分别是探索类算子、清洗类算子、转换类算子和分析类算子。
所谓行百里者半九十纵观整个生命周期我们还剩下数据持久化这一个环节。对于最后的这个持久化环节Spark SQL提供了write API与上一讲介绍的read API相对应write API允许开发者把数据灵活地物化为不同的文件格式。
持久化类算子
没有对比就没有鉴别在学习write API之前我们不妨先来回顾一下上一讲介绍的read API。
如上图所示read API有3个关键点一是由format指定的文件格式二是由零到多个option组成的加载选项最后一个是由load标记的源文件路径。
与之相对write API也有3个关键环节分别是同样由format定义的文件格式零到多个由option构成的“写入选项”以及由save指定的存储路径如下图所示。
这里的format和save与read API中的format和load是一一对应的分别用于指定文件格式与存储路径。实际上option选项也是类似的除了mode以外write API中的选项键与read API中的选项键也是相一致的如seq用于指定CSV文件分隔符、dbtable用于指定数据表名、等等你可以通过回顾[上一讲]来获取更多的option选项。
在read API中mode选项键用于指定读取模式如permissive, dropMalformed, failFast。但在write API中mode用于指定“写入模式”分别有Append、Overwrite、ErrorIfExists、Ignore这4种模式它们的含义与描述如下表所示。
有了write API我们就可以灵活地把DataFrame持久化到不同的存储系统中为数据的生命周期画上一个圆满的句号。
重点回顾
今天这一讲我们主要围绕数据的生命周期学习了Spark SQL在不同数据阶段支持的处理算子如下图所示。
图中涉及的算子很多尽管大部分我们都举例讲过了但要在短时间之内一下子掌握这么多内容确实强人所难。不过你不用担心今天这一讲最主要的目的还是想让你对Spark SQL支持的算子有一个整体的把握。
至于每个算子具体是用来做什么的在日后的开发工作中你可以反复地翻看这一讲结合实践慢慢地加深印象这样学习更高效。我也强烈建议你空闲时把官网的Built-in Functions列表过一遍对这些内置函数的功能做到心中有数实现业务逻辑时才会手到擒来。
除了DataFrame本身支持的算子之外在功能上SQL完全可以实现同样的数据分析。给定DataFrame你只需通过createTempView或是createGlobalTempView来创建临时表然后就可以通过写SQL语句去进行数据的探索、倾斜、转换与分析。
最后需要指出的是DataFrame算子与SQL查询语句之间并没有优劣之分他们可以实现同样的数据应用而且在执行性能方面也是一致的。因此你可以结合你的开发习惯与偏好自由地在两者之间进行取舍。
每课一练
在转换类算子中我们举例介绍了explode这个算子它的作用是按照以数组为元素的数据列把一条数据展开爆炸成多条数据。结合这个算子的作用你能否分析一下explode操作是否会引入Shuffle计算呢
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给有需要的朋友。

View File

@ -0,0 +1,323 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 数据关联:不同的关联形式与实现机制该怎么选?
你好,我是吴磊。
在上一讲我们学习了Spark SQL支持的诸多算子。其中数据关联Join是数据分析场景中最常见、最重要的操作。毫不夸张地说几乎在所有的数据应用中你都能看到数据关联的“身影”。因此今天这一讲咱们继续详细说一说Spark SQL对于Join的支持。
众所周知Join的种类非常丰富。如果按照关联形式Join Types来划分数据关联分为内关联、外关联、左关联、右关联等等。对于参与关联计算的两张表关联形式决定了结果集的数据来源。因此在开发过程中选择哪种关联形式是由我们的业务逻辑决定的。
而从实现机制的角度Join又可以分为NLJNested Loop Join、SMJSort Merge Join和HJHash Join。也就是说同样是内关联我们既可以采用NLJ来实现也可以采用SMJ或是HJ来实现。区别在于在不同的计算场景下这些不同的实现机制在执行效率上有着天壤之别。因此了解并熟悉这些机制对咱们开发者来说至关重要。
今天我们就分别从这两个角度来说一说Spark SQL当中数据关联的来龙去脉。
数据准备
为了让你更好地掌握新知识我会通过一个个例子为你说明Spark SQL数据关联的具体用法。在去介绍数据关联之前咱们先把示例中会用到的数据准备好。
import spark.implicits._
import org.apache.spark.sql.DataFrame
// 创建员工信息表
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"), (5, "Dave", 36, "Male"))
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
// 创建薪资表
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
val salaries:DataFrame = seq2.toDF("id", "salary")
如上表所示我们创建了两个DataFrame一个用于存储员工基本信息我们称之为员工表另一个存储员工薪水我们称之为薪资表。
数据准备好之后我们有必要先弄清楚一些数据关联的基本概念。所谓数据关联它指的是这样一个计算过程给定关联条件Join Conditions将两张数据表以不同关联形式拼接在一起的过程。关联条件包含两层含义一层是两张表中各自关联字段Join Key的选择另一层是关联字段之间的逻辑关系。
在[上一讲]我们说到Spark SQL同时支持DataFrame算子与SQL查询因此咱们不妨结合刚刚准备好的数据分别以这两者为例来说明数据关联中的基本概念。
首先约定俗成地我们把主动参与Join的数据表如上图中的salaries表称作“左表”而把被动参与关联的数据表如employees表称作是“右表”。
然后我们来关注图中蓝色的部分。可以看到两张表都选择id列作为关联字段而两者的逻辑关系是“相等”。这样的一个等式就构成了我们刚刚说的关联条件。接下来我们再来看图中绿色的部分inner指代的就是内关联的关联形式。
关联形式是我们今天要学习的重点内容之一。接下来我们还是一如既往地绕过SQL查询这种开发方式以DataFrame算子这种开发模式为例说一说Spark SQL都支持哪些关联形式以及不同关联形式的效果是怎样的。
关联形式Join Types
在关联形式这方面Spark SQL的支持比较全面为了让你一上来就建立一个整体的认知我把Spark SQL支持的Joint Types都整理到了如下的表格中你不妨先粗略地过一遍。
结合已经准备好的数据,我们分别来说一说每一种关联形式的用法,以及它们各自的作用与效果。我们先从最简单、最基础、也是最常见的内关联说起。
内关联Inner Join
对于登记在册的员工,如果我们想获得他们每个人的薪资情况,就可以使用内关联来实现,如下所示。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
// 左表
salaries.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
// 右表
employees.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| Mike| 28| Male|
| 2| Lily| 30|Female|
| 3|Raymond| 26| Male|
| 5| Dave| 36| Male|
+---+-------+---+------+
*/
可以看到基于join算子的一般用法我们只要在第3个参数中指定“inner”这种关联形式就可以使用内关联的方式来达成两表之间的数据拼接。不过如果仔细观察上面打印的关联结果集以及原始的薪资表与员工表你会发现左表和右表的原始数据并没有都出现在结果集当中。
例如在原始的薪资表中有一条id为4的薪资记录而在员工表中有一条id为5、name为“Dave”的数据记录。这两条数据记录都没有出现在内关联的结果集中而这正是“内关联”这种关联形式的作用所在。
内关联的效果是仅仅保留左右表中满足关联条件的那些数据记录。以上表为例关联条件是salaries(“id”) === employees(“id”)而在员工表与薪资表中只有1、2、3这三个值同时存在于他们各自的id字段中。相应地结果集中就只有id分别等于1、2、3的这三条数据记录。
理解了内关联的含义与效果之后,你再去学习其他的关联形式,比如说外关联,就会变得轻松许多。
外关联Outer Join
外关联还可以细分为3种形式分别是左外关联、右外关联、以及全外关联。这里的左、右对应的实际上就是左表、右表。
由简入难我们先来说左外关联。要把salaries与employees做左外关联我们只需要把“inner”关键字替换为“left”、“leftouter”或是“left_outer”即可如下所示。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "left")
jointDF.show
/** 结果打印
+---+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+---+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 4| 25000|null| null|null| null|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+----+-------+----+------+
*/
不难发现左外关联的结果集实际上就是内关联结果集再加上左表salaries中那些不满足关联条件的剩余数据也即id为4的数据记录。值得注意的是由于右表employees中并不存在id为4的记录因此结果集中employees对应的所有字段值均为空值null。
没有对比就没有鉴别为了更好地理解前面学的内关联、左外关联我们再来看看右外关联的执行结果。为了计算右外关联在下面的代码中我们把“left”关键字替换为“right”、“rightouter”或是“right_outer”。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "right")
jointDF.show
/** 结果打印
+----+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+----+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
+----+------+---+-------+---+------+
*/
仔细观察你会发现与左外关联相反右外关联的结果集恰恰是内关联的结果集再加上右表employees中的剩余数据也即id为5、name为“Dave”的数据记录。同样的由于左表salaries并不存在id等于5的数据记录因此结果集中salaries相应的字段置空以null值进行填充。
理解了左外关联与右外关联全外关联的功用就显而易见了。全外关联的结果集就是内关联的结果再加上那些不满足关联条件的左右表剩余数据。要进行全外关联的计算关键字可以取“full”、“outer”、“fullouter”、或是“full_outer”如下表所示。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "full")
jointDF.show
/** 结果打印
+----+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+----+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
| 4| 25000|null| null|null| null|
| 2| 30000| 2| Lily| 30|Female|
+----+------+----+-------+----+------+
*/
到这里,内、外关联的作用我们就讲完了。聪明的你可能早已发现,这里的“内”,它指的是,在关联结果中,仅包含满足关联条件的那些数据记录;而“外”,它的含义是,在关联计算的结果集中,还包含不满足关联条件的数据记录。而外关联中的“左”、“右”、“全”,恰恰是在表明,那些不满足关联条件的记录,来自于哪里。
弄清楚“内”、“外”、“左”、“右”这些说法的含义能够有效地帮我们避免迷失在种类繁多、却又彼此相关的关联形式中。其实除了内关联和外关联Spark SQL还支持左半关联和左逆关联这两个关联又是用来做什么的呢
左半/逆关联Left Semi Join / Left Anti Join
尽管名字听上去拗口但它们的含义却很简单。我们先来说左半关联它的关键字有“leftsemi”和“left_semi”。左半关联的结果集实际上是内关联结果集的子集它仅保留左表中满足关联条件的那些数据记录如下表所示。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
// 左半关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftsemi")
jointDF.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 3| 20000|
+---+------+
*/
为了方便你进行对比我分别打印出了内关联与左半关联的计算结果。这里你需要把握左半关联的两大特点首先左半关联是内关联的一个子集其次它只保留左表salaries中的数据。这两个特点叠加在一起很好地诠释了“左、半”这两个字。
有了左半关联的基础左逆关联会更好理解一些。左逆关联同样只保留左表的数据它的关键字有“leftanti”和“left_anti”。但与左半关联不同的是它保留的是那些不满足关联条件的数据记录如下所示。
// 左逆关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftanti")
jointDF.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 4| 25000|
+---+------+
*/
通过与上面左半关联的结果集做对比我们一眼就能看出左逆关联和它的区别所在。显然id为4的薪资记录是不满足关联条件salaries(“id”) === employees(“id”)的,而左逆关联留下的,恰恰是这些“不达标”的数据记录。
好啦关于Spark SQL支持的关联形式到这里我们就全部说完了。根据这些不同关联形式的特点与作用再结合实际场景中的业务逻辑相信你可以在日常的开发中做到灵活取舍。
关联机制Join Mechanisms
不过从功能的角度出发使用不同的关联形式来实现业务逻辑可以说是程序员的一项必备技能。要在众多的开发者中脱颖而出咱们还要熟悉、了解不同的关联机制。哪怕同样是内关联不同的Join实现机制在执行效率方面差异巨大。因此掌握不同关联机制的原理与特性有利于我们逐渐培养出以性能为导向的开发习惯。
在这一讲的开头我们提到Join有3种实现机制分别是NLJNested Loop Join、SMJSort Merge Join和HJHash Join。接下来我们以内关联为例结合salaries和employees这两张表来说说它们各自的实现原理与特性。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
NLJNested Loop Join
对于参与关联的两张表如salaries和employees按照它们在代码中出现的顺序我们约定俗成地把salaries称作“左表”而把employees称作“右表”。在探讨关联机制的时候我们又常常把左表称作是“驱动表”而把右表称为“基表”。
一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。
在NLJ的实现机制下算法会使用外、内两个嵌套的for循环来依次扫描驱动表与基表中的数据记录。在扫描的同时还会判定关联条件是否成立如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。
在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录依次判断记录的id字段值是否满足关联条件如步骤 2 所示。
不难发现,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管NLJ的实现方式简单、直观、易懂但它的执行效率显然很差。
SMJSort Merge Join
鉴于NLJ低效的计算效率SMJ应运而生。Sort Merge Join顾名思义SMJ的实现思路是先排序、再归并。给定参与关联的两张表SMJ先把他们各自排序然后再使用独立的游标对排好序的两张表做归并关联。
具体计算过程是这样的起初驱动表与基表的游标都会先锚定在各自的第一条记录上然后通过对比游标所在记录的id字段值来决定下一步的走向。对比结果以及后续操作主要分为 3 种情况:
满足关联条件两边的id值相等那么此时把两边的数据记录拼接并输出然后把驱动表的游标滑动到下一条记录
不满足关联条件驱动表id值小于基表的id值此时把驱动表的游标滑动到下一条记录
不满足关联条件驱动表id值大于基表的id值此时把基表的游标滑动到下一条记录。
基于这 3 种情况SMJ不停地向下滑动游标直到某张表的游标滑到尽头即宣告关联结束。对于驱动表的每一条记录由于基表已按id字段排序且扫描的起始位置为游标所在位置因此SMJ算法的计算复杂度为 O(M + N)。
然而,计算复杂度的降低,仰仗的其实是两张表已经事先排好了序。但是我们知道,排序本身就是一项很耗时的操作,更何况,为了完成归并关联,参与 Join 的两张表都需要排序。
因此SMJ的计算过程我们可以用“先苦后甜”来形容。苦指的是要先花费时间给两张表做排序而甜指的则是有序表的归并关联能够享受到线性的计算复杂度。
HJHash Join
考虑到SMJ对于排序的苛刻要求后来又有人推出了HJ算法。HJ的设计初衷是以空间换时间力图将基表扫描的计算复杂度降低至O(1)。
具体来说HJ的计算分为两个阶段分别是Build阶段和Probe阶段。在Build阶段在基表之上算法使用既定的哈希函数构建哈希表如上图的步骤 1 所示。哈希表中的Key是id字段应用Apply哈希函数之后的哈希值而哈希表的 Value 同时包含了原始的Join Keyid字段和Payload。
在Probe阶段算法依次遍历驱动表的每一条数据记录。首先使用同样的哈希函数以动态的方式计算Join Key的哈希值。然后算法再用哈希值去查询刚刚在Build阶段创建好的哈希表。如果查询失败则说明该条记录与基表中的数据不存在关联关系相反如果查询成功则继续对比两边的Join Key。如果Join Key一致就把两边的记录进行拼接并输出从而完成数据关联。
好啦到此为止对于Join的3种实现机制我们暂时说到这里。对于它们各自的实现原理想必你已经有了充分的把握。至于这3种机制都适合哪些计算场景以及Spark SQL如何利用这些机制在分布式环境下做数据关联我们留到[下一讲]再去展开。
重点回顾
今天这一讲我们重点介绍了数据关联中的关联形式Join Types与实现机制Join Mechanisms。掌握了不同的关联形式我们才能游刃有余地满足不断变化的业务需求。而熟悉并理解不同实现机制的工作原理则有利于培养我们以性能为导向的开发习惯。
Spark SQL支持的关联形式多种多样为了方便你查找我把它们的含义与效果统一整理到了如下的表格中。在日后的开发工作中当你需要区分并确认不同的关联形式时只要回顾这张表格就能迅速得到结论。
在此之后我们又介绍了Join的3种实现机制它们分别是Nested Loop Join、Sort Merge Join和Hash Join。这3种实现机制的工作原理我也整理成了表格方便你随时查看。
每课一练
对于Join的3种实现机制也即Nested Loop Join、Sort Merge Join和Hash Join结合其实现原理你能猜一猜它们可能的适用场景都有哪些吗或者换句话说在什么样的情况下更适合使用哪种实现机制来进行数据关联
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给身边的同事、朋友。

View File

@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 数据关联优化都有哪些Join策略开发者该如何取舍
你好,我是吴磊。
在上一讲,我们分别从关联形式与实现机制这两个方面,对数据分析进行了讲解和介绍。对于不同关联形式的用法和实现机制的原理,想必你已经了然于胸。不过,在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。
我们知道在分布式环境中Spark支持两类数据分发模式。一类是我们在[第7讲]学过的ShuffleShuffle通过中间文件来完成Map阶段与Reduce阶段的数据交换因此它会引入大量的磁盘与网络开销。另一类是我们在[第10讲]介绍的广播变量Broadcast Variables广播变量在Driver端创建并由Driver分发到各个Executors。
因此从数据分发模式的角度出发数据关联又可以分为Shuffle Join和Broadcast Join这两大类。将两种分发模式与Join本身的3种实现机制相结合就会衍生出分布式环境下的6种Join策略。
那么对于这6种Join策略Spark SQL是如何支持的呢它们的优劣势与适用场景都有哪些开发者能否针对这些策略有的放矢地进行取舍今天这一讲咱们就来聊聊这些话题。
Join实现机制的优势对比
首先我们先来说一说不同Join实现机制本身的一些特性与适用场景从而为后续的讨论打好基础。需要说明的是咱们这里说的Join实现机制指的是算法层面的工作原理不同的算法有着不同的适用场景与复杂度我们需要对它们有足够认识并有所区分。
我们知道Join支持3种实现机制它们分别是Hash Join、Sort Merge Join和Nested Loop Join。三者之中Hash Join的执行效率最高这主要得益于哈希表O(1)的查找效率。不过在Probe阶段享受哈希表的“性能红利”之前Build阶段得先在内存中构建出哈希表才行。因此Hash Join这种算法对于内存的要求比较高适用于内存能够容纳基表数据的计算场景。
相比之下Sort Merge Join就没有内存方面的限制。不论是排序、还是合并SMJ都可以利用磁盘来完成计算。所以在稳定性这方面SMJ更胜一筹。
而且与Hash Join相比SMJ的执行效率也没有差太多前者是O(M)后者是O(M + N)可以说是不分伯仲。当然O(M + N)的复杂度得益于SMJ的排序阶段。因此如果准备参与Join的两张表是有序表那么这个时候采用SMJ算法来实现关联简直是再好不过了。
与前两者相比Nested Loop Join看上去有些多余嵌套的双层for循环带来的计算复杂度最高O(M * N)。不过尺有所短寸有所长执行高效的HJ和SMJ只能用于等值关联也就是说关联条件必须是等式像salaries(“id”) < employees(id)这样的关联条件HJ和SMJ是无能为力的相反NLJ既可以处理等值关联Equi Join也可以应付不等值关联Non Equi Join可以说是数据关联在实现机制上的最后一道防线
Shuffle Join与Broadcast Join
分析完不同Join机制的优缺点之后接下来我们再来说说分布式环境下的Join策略。与单机环境不同在分布式环境中两张表的数据各自散落在不同的计算节点与Executors进程。因此要想完成数据关联Spark SQL就必须先要把Join Keys相同的数据分发到同一个Executors中去才行。
我们还是用上一讲的员工信息和薪资表来举例如果我们打算对salaries和employees两张表按照id列做关联那么对于id字段值相同的薪资数据与员工数据我们必须要保证它们坐落在同样的Executors进程里Spark SQL才能利用刚刚说的HJ、SMJ、以及NLJ以Executors进程为粒度并行地完成数据关联。
换句话说以Join Keys为基准两张表的数据分布保持一致是Spark SQL执行分布式数据关联的前提。而能满足这个前提的途径只有两个Shuffle与广播。这里我额外提醒一下Shuffle和广播变量我们在前面的课程有过详细的介绍如果你记不太清了不妨翻回去看一看。
回到正题开篇咱们说到如果按照分发模式来划分数据关联可以分为Shuffle Join和Broadcast Join两大类。通常来说在执行性能方面相比Shuffle JoinBroadcast Join往往会更胜一筹。为什么这么说呢
接下来我们就一起来分析分析这两大类Join在分布式环境下的执行过程然后再来回答这个问题。理解了执行过程你自然就能解答这个问题了。
Shuffle Join
在没有开发者干预的情况下Spark SQL默认采用Shuffle Join来完成分布式环境下的数据关联。对于参与Join的两张数据表Spark SQL先是按照如下规则来决定不同数据记录应当分发到哪个Executors中去
根据Join Keys计算哈希值
将哈希值对并行度Parallelism取模
由于左表与右表在并行度分区数上是一致的因此按照同样的规则分发数据之后一定能够保证id字段值相同的薪资数据与员工数据坐落在同样的Executors中。
如上图所示颜色相同的矩形代表Join Keys相同的数据记录可以看到在Map阶段数据分散在不同的Executors当中。经过Shuffle过后Join Keys相同的记录被分发到了同样的Executors中去。接下来在Reduce阶段Reduce Task就可以使用HJ、SMJ、或是NLJ算法在Executors内部完成数据关联的计算。
Spark SQL之所以在默认情况下一律采用Shuffle Join原因在于Shuffle Join的“万金油”属性。也就是说在任何情况下不论数据的体量是大是小、不管内存是否足够Shuffle Join在功能上都能够“不辱使命”成功地完成数据关联的计算。然而有得必有失功能上的完备性往往伴随着的是性能上的损耗。
学习过 [Shuffle的原理]第6讲之后不用我多说Shuffle的弊端想必你早已烂熟于心。我们知道从CPU到内存从磁盘到网络Shuffle的计算几乎需要消耗所有类型的硬件资源。尤其是磁盘和网络开销这两座大山往往是应用执行的性能瓶颈。
那么问题来了除了Shuffle Join这种“万金油”式的Join策略开发者还有没有其他效率更高的选择呢答案当然是肯定的Broadcast Join就是用来克制Shuffle的“杀手锏”。
Broadcast Join
在广播变量那一讲第10讲我们讲过把用户数据结构封装为广播变量的过程。实际上Spark不仅可以在普通变量上创建广播变量在分布式数据集如RDD、DataFrame之上也可以创建广播变量。这样一来对于参与Join的两张表我们可以把其中较小的一个封装为广播变量然后再让它们进行关联。
光说思路你可能体会不深,我们还是结合例子理解。以薪资表和员工表为例,只要对代码稍加改动,我们就能充分利用广播变量的优势。
更改后的代码如下所示。
import org.apache.spark.sql.functions.broadcast
// 创建员工表的广播变量
val bcEmployees = broadcast(employees)
// 内关联PS将原来的employees替换为bcEmployees
val jointDF: DataFrame = salaries.join(bcEmployees, salaries("id") === employees("id"), "inner")
在Broadcast Join的执行过程中Spark SQL首先从各个Executors收集employees表所有的数据分片然后在Driver端构建广播变量bcEmployees构建的过程如下图实线部分所示。
可以看到散落在不同Executors内花花绿绿的矩形代表的正是employees表的数据分片。这些数据分片聚集到一起就构成了广播变量。接下来如图中虚线部分所示携带着employees表全量数据的广播变量bcEmployees被分发到了全网所有的Executors当中去。
在这种情况下体量较大的薪资表数据只要“待在原地、保持不动”就可以轻松关联到跟它保持之一致的员工表数据了。通过这种方式Spark SQL成功地避开了Shuffle这种“劳师动众”的数据分发过程转而用广播变量的分发取而代之。
尽管广播变量的创建与分发同样需要消耗网络带宽但相比Shuffle Join中两张表的全网分发因为仅仅通过分发体量较小的数据表来完成数据关联Spark SQL的执行性能显然要高效得多。这种小投入、大产出用极小的成本去博取高额的性能收益可以说是“四两拨千斤”
Spark SQL支持的Join策略
不论是Shuffle Join还是Broadcast Join一旦数据分发完毕理论上可以采用HJ、SMJ和NLJ这3种实现机制中的任意一种完成Executors内部的数据关联。因此两种分发模式与三种实现机制它们组合起来总共有6种分布式Join策略如下图所示。
虽然组合起来选择多样,但你也不必死记硬背,抓住里面的规律才是关键,我们一起来分析看看。
在这6种Join策略中Spark SQL支持其中的5种来应对不用的关联场景也即图中蓝色的5个矩形。对于等值关联Equi JoinSpark SQL优先考虑采用Broadcast HJ策略其次是Shuffle SMJ最次是Shuffle HJ。对于不等值关联Non Equi JoinSpark SQL优先考虑Broadcast NLJ其次是Shuffle NLJ。
不难发现不论是等值关联、还是不等值关联只要Broadcast Join的前提条件成立Spark SQL一定会优先选择Broadcast Join相关的策略。那么问题来了Broadcast Join的前提条件是什么呢
回顾Broadcast Join的工作原理图我们不难发现Broadcast Join得以实施的基础是被广播数据表图中的表2的全量数据能够完全放入Driver的内存、以及各个Executors的内存如下图所示。
另外为了避免因广播表尺寸过大而引入新的性能隐患Spark SQL要求被广播表的内存大小不能超过8GB。
这里我们简单总结一下。只要被广播表满足上述两个条件我们就可以利用SQL Functions中的broadcast函数来创建广播变量进而利用Broadcast Join策略来提升执行性能。
当然在Broadcast Join前提条件不成立的情况下Spark SQL就会退化到Shuffle Join的策略。在不等值的数据关联中Spark SQL只有Shuffle NLJ这一种选择因此咱们无需赘述。
但在等值关联的场景中Spark SQL有Shuffle SMJ和Shuffle HJ这两种选择。尽管如此Shuffle SMJ与Shuffle HJ的关系就像是关羽和周仓的关系。周仓虽说武艺也不错但他向来只是站在关公后面提刀。大战在即刘备仰仗的自然是站在前面的关羽而很少启用后面的周仓。在Shuffle SMJ与Shuffle HJ的取舍上Spark SQL也是如此。
学习过Shuffle之后我们知道Shuffle在Map阶段往往会对数据做排序而这恰恰正中SMJ机制的下怀。对于已经排好序的两张表SMJ的复杂度是O(M + N)这样的执行效率与HJ的O(M)可以说是不相上下。再者SMJ在执行稳定性方面远胜于HJ在内存受限的情况下SMJ可以充分利用磁盘来顺利地完成关联计算。因此考虑到Shuffle SMJ的诸多优势Shuffle HJ就像是关公后面的周仓Spark SQL向来对之视而不见所以对于HJ你大概知道它的作用就行。
重点回顾
好啦到此为止今天的课程就全部讲完了我们一起来做个总结。首先我们一起分析、对比了单机环境中不同Join机制的优劣势我把它们整理到了下面的表格中供你随时查看。
在分布式环境中要想利用上述机制完成数据关联Spark SQL首先需要把两张表中Join Keys一致的数据分发到相同的Executors中。
因此数据分发是分布式数据关联的基础和前提。Spark SQL支持Shuffle和广播两种数据分发模式相应地Join也被分为Shuffle Join和Broadcast Join其中Shuffle Join是默认的关联策略。关于两种策略的优劣势对比我也整理到了如下的表格中供你参考。
结合三种实现机制和两种数据分发模式Spark SQL支持5种分布式Join策略。对于这些不同的Join策略Spark SQL有着自己的选择偏好我把它整理到了如下的表格中供你随时查看。
其中Broadcast Join的生效前提是基表能够放进内存且存储尺寸小于8GB。只要前提条件成立Spark SQL就会优先选择Broadcast Join。
每课一练
在6种分布式Join策略中Spark SQL唯独没有支持Broadcast SMJ你能想一想为什么Spark SQL没有选择支持这种Join策略吗提示一下你可以从SMJ与HJ的执行效率入手做分析。
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。

View File

@ -0,0 +1,188 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 配置项详解:哪些参数会影响应用程序执行性能?
你好,我是吴磊。
在上一讲我们学习了Broadcast Join这种执行高效的Join策略。要想触发Spark SQL选择这类Join策略可以利用SQL Functions中的broadcast函数来强制广播基表。在这种情况下Spark SQL会完全“尊重”开发者的意愿只要基表小于8GB它就会竭尽全力地去尝试进行广播并采用Broadcast Join策略。
除了这种比较“强势”的做法我们还可以用另一种比较温和方式来把选择权“下放”给Spark SQL让它自己来决定什么时候选择Broadcast Join什么时候回退到Shuffle Join。这种温和的方式就是配置项设置。在第12讲我们掌握了Spark常规配置项今天这一讲咱们来说一说与Spark SQL有关的那些配置项。
不过打开Spark官网的 Configuration页面你会发现这里有上百个配置项与Spark SQL相关的有好几十个看得人眼花缭乱、头晕目眩。实际上绝大多数配置项仅需采用默认值即可并不需要我们过多关注。因此我们把目光和注意力聚集到Join策略选择和AQE上。
Join策略的重要性不必多说AQEAdaptive Query Execution是Spark 3.0推出的新特性它帮助Spark SQL在运行时动态地调整执行计划更加灵活地优化作业的执行性能。
Broadcast Join
接下来我们先来说说如何使用配置项来“温和”地让Spark SQL选择Broadcast Join。对于参与Join的两张表来说我们把其中尺寸较小的表称作基表。
如果基表的存储尺寸小于广播阈值那么无需开发者显示调用broadcast函数Spark SQL同样会选择Broadcast Join的策略在基表之上创建广播变量来完成两张表的数据关联。
那么问题来了广播阈值是什么它是怎么定义的呢广播阈值实际上就是一个标记存储尺寸的数值它可以是10MB、也可是1GB等等。广播阈值由如下配置项设定只要基表小于该配置项的设定值Spark SQL就会自动选择Broadcast Join策略。
如上表所示广播阈值的默认值为10MB。一般来说在工业级应用中我们往往把它设置到2GB左右即可有效触发Broadcast Join。广播阈值有了要比较它与基表存储尺寸谁大谁小Spark SQL还要还得事先计算好基表的存储尺寸才行。那问题来了Spark SQL依据什么来计算这个数值呢
这个问题要分两种情况讨论如果基表数据来自文件系统那么Spark SQL用来与广播阈值对比的基准就是基表在磁盘中的存储大小。如果基表数据来自DAG计算的中间环节那么Spark SQL将参考DataFrame执行计划中的统计值跟广播阈值做对比如下所示。
val df: DataFrame = _
// 先对分布式数据集加Cache
df.cache.count
// 获取执行计划
val plan = df.queryExecution.logical
// 获取执行计划对于数据集大小的精确预估
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
讲到这里你也许会有点不耐烦“何必这么麻烦又要设置配置项又要提前预估基表大小真是麻烦还不如用上一讲提到的broadcast函数来得干脆
从开发者的角度看来确实broadcast函数用起来更方便一些。不过广播阈值加基表预估的方式除了为开发者提供一条额外的调优途径外还为Spark SQL的动态优化奠定了基础。
所谓动态优化自然是相对静态优化来说的。在3.0版本之前对于执行计划的优化Spark SQL仰仗的主要是编译时运行时之前的统计信息如数据表在磁盘中的存储大小等等。
因此在3.0版本之前Spark SQL所有的优化机制如Join策略的选择都是静态的它没有办法在运行时动态地调整执行计划从而顺应数据集在运行时此消彼长的变化。
举例来说在Spark SQL的逻辑优化阶段两张大表的尺寸都超过了广播阈值因此Spark SQL在物理优化阶段就不得不选择Shuffle Join这种次优的策略。
但实际上在运行时期间其中一张表在Filter过后剩余的数据量远小于广播阈值完全可以放进广播变量。可惜此时“木已成舟”静态优化机制没有办法再将Shuffle Join调整为Broadcast Join。
AQE
为了弥补静态优化的缺陷、同时让Spark SQL变得更加智能Spark社区在3.0版本中推出了AQE机制。
AQE的全称是Adaptive Query Execution翻译过来是“自适应查询执行”。它包含了3个动态优化特性分别是Join策略调整、自动分区合并和自动倾斜处理。
或许是Spark社区对于新的优化机制偏向于保守AQE机制默认是未开启的要想充分利用上述的3个特性我们得先把spark.sql.adaptive.enabled修改为true才行。
好啦成功开启了AQE机制之后接下来我们就结合相关的配置项来聊一聊这些特性都解决了哪些问题以及它们是如何工作的。
Join策略调整
我们先来说说Join策略调整如果用一句话来概括Join策略调整指的就是Spark SQL在运行时动态地将原本的Shuffle Join策略调整为执行更加高效的Broadcast Join。
具体来说每当DAG中的Map阶段执行完毕Spark SQL就会结合Shuffle中间文件的统计信息重新计算Reduce阶段数据表的存储大小。如果发现基表尺寸小于广播阈值那么Spark SQL就把下一阶段的Shuffle Join调整为Broadcast Join。
不难发现这里的关键是Shuffle以及Shuffle的中间文件。事实上不光是Join策略调整这个特性整个AQE机制的运行都依赖于DAG中的Shuffle环节。
所谓巧妇难为无米之炊要做到动态优化Spark SQL必须要仰仗运行时的执行状态而Shuffle中间文件则是这些状态的唯一来源。
举例来说通过Shuffle中间文件Spark SQL可以获得诸如文件尺寸、Map Task数据分片大小、Reduce Task分片大小、空文件占比之类的统计信息。正是利用这些统计信息Spark SQL才能在作业执行的过程中动态地调整执行计划。
我们结合例子进一步来理解以Join策略调整为例给定如下查询语句假设salaries表和employees表的存储大小都超过了广播阈值在这种情况下对于两张表的关联计算Spark SQL只能选择Shuffle Join策略。
不过实际上employees按照年龄过滤之后剩余的数据量是小于广播阈值的。这个时候得益于AQE机制的Join策略调整Spark SQL能够把最初制定的Shuffle Join策略调整为Broadcast Join策略从而在运行时加速执行性能。
select * from salaries inner join employees
on salaries.id = employees.id
where employees.age >= 30 and employees.age < 45
你看在这种情况下广播阈值的设置、以及基表过滤之后数据量的预估就变得非常重要。原因在于这两个要素决定了Spark SQL能否成功地在运行时充分利用AQE的Join策略调整特性进而在整体上优化执行性能。因此我们必须要掌握广播阈值的设置方法以及数据集尺寸预估的方法。
介绍完Join策略调整接下来我们再来说说AQE机制的另外两个特性自动分区合并与自动倾斜处理它们都是对于Shuffle本身的优化策略。
我们先来说说自动分区合并与自动倾斜处理都在尝试解决什么问题。我们知道Shuffle的计算过程分为两个阶段Map阶段和Reduce阶段。Map阶段的数据分布往往由分布式文件系统中的源数据决定因此数据集在这个阶段的分布是相对均匀的。
Reduce阶段的数据分布则不同它是由Distribution Key和Reduce阶段并行度决定的。并行度也就是分区数目这个概念咱们在之前的几讲反复强调想必你并不陌生。
而Distribution Key则定义了Shuffle分发数据的依据对于reduceByKey算子来说Distribution Key就是Paired RDD的Key而对于repartition算子来说Distribution Key就是传递给repartition算子的形参如repartition($“Column Name”)。
在业务上Distribution Key往往是user_id、item_id这一类容易产生倾斜的字段相应地数据集在Reduce阶段的分布往往也是不均衡的。
数据的不均衡往往体现在两个方面一方面是一部分数据分区的体量过小而另一方面则是少数分区的体量极其庞大。AQE机制的自动分区合并与自动倾斜处理正是用来应对数据不均衡的这两个方面。
自动分区合并
了解了自动分区合并的用武之地接下来我们来说说Spark SQL具体如何做到把Reduce阶段过小的分区合并到一起。要弄清楚分区合并的工作原理我们首先得搞明白“分区合并从哪里开始又到哪里结束呢
具体来说Spark SQL怎么判断一个数据分区是不是足够小、它到底需不需要被合并再者既然是对多个分区做合并那么自然就存在一个收敛条件。原因很简单如果一直不停地合并下去那么整个数据集就被捏合成了一个超级大的分区并行度也会下降至1显然这不是我们想要的结果。
事实上Spark SQL采用了一种相对朴素的方法来实现分区合并。具体来说Spark SQL事先并不去判断哪些分区是不是足够小而是按照分区的编号依次进行扫描当扫描过的数据体量超过了“目标尺寸”时就进行一次合并。而这个目标尺寸由以下两个配置项来决定。
其中开发者可以通过第一个配置项spark.sql.adaptive.advisoryPartitionSizeInBytes来直接指定目标尺寸。第二个参数用于限制Reduce阶段在合并之后的并行度避免因为合并导致并行度过低造成CPU资源利用不充分。
结合数据集大小与最低并行度,我们可以反推出来每个分区的平均大小,假设我们把这个平均大小记作是#partitionSize。那么实际的目标尺寸取advisoryPartitionSizeInBytes设定值与#partitionSize之间较小的那个数值
确定了目标尺寸之后Spark SQL就会依序扫描数据分区当相邻分区的尺寸之和大于目标尺寸的时候Spark SQL就把扫描过的分区做一次合并。然后继续使用这种方式依次合并剩余的分区直到所有分区都处理完毕。
自动倾斜处理
没有对比就没有鉴别,分析完自动分区合并如何搞定数据分区过小、过于分散的问题之后,接下来,我们再来说一说,自动倾斜处理如何应对那些倾斜严重的大分区。
经过上面的分析我们不难发现自动分区合并实际上包含两个关键环节一个是确定合并的目标尺寸一个是依次扫描合并。与之相对应自动倾斜处理也分为两步第一步是检测并判定体量较大的倾斜分区第二步是把这些大分区拆分为小分区。要做到这两步Spark SQL需要依赖如下3个配置项。
其中前两个配置项用于判定倾斜分区第3个配置项advisoryPartitionSizeInBytes我们刚刚学过这个参数除了用于合并小分区外同时还用于拆分倾斜分区可以说是“一菜两吃”。
下面我们重点来讲一讲Spark SQL如何利用前两个参数来判定大分区的过程。
首先Spark SQL对所有数据分区按照存储大小做排序取中位数作为基数。然后将中位数乘以skewedPartitionFactor指定的比例系数得到判定阈值。凡是存储尺寸大于判定阈值的数据分区都有可能被判定为倾斜分区。
为什么说“有可能”而不是“一定”呢原因是倾斜分区的判定还要受到skewedPartitionThresholdInBytes参数的限制它是判定倾斜分区的最低阈值。也就是说只有那些尺寸大于skewedPartitionThresholdInBytes设定值的“候选分区”才会最终判定为倾斜分区。
为了更好地理解这个判定的过程我们来举个例子。假设数据表salaries有3个分区大小分别是90MB、100MB和512MB。显然这3个分区的中位数是100MB那么拿它乘以比例系数skewedPartitionFactor默认值为5得到判定阈值为100MB * 5 = 500MB。因此在咱们的例子中只有最后一个尺寸为512MB的数据分区会被列为“候选分区”。
接下来Spark SQL还要拿512MB与skewedPartitionThresholdInBytes作对比这个参数的默认值是256MB。
显然512MB比256MB要大得多这个时候Spark SQL才会最终把最后一个分区判定为倾斜分区。相反假设我们把skewedPartitionThresholdInBytes这个参数调大设置为1GB那么最后一个分区就不满足最低阈值因此也就不会被判定为倾斜分区。
倾斜分区判定完毕之后下一步就是根据advisoryPartitionSizeInBytes参数指定的目标尺寸对大分区进行拆分。假设我们把这个参数的值设置为256MB那么刚刚512MB的大分区就会被拆成两个小分区512MB / 2 = 256MB。拆分之后salaries表就由3个分区变成了4个分区每个数据分区的尺寸都不超过256MB。
重点回顾
好啦到此为止与Spark SQL相关的重要配置项我们就讲到这里。今天的内容很多我们一起来总结一下。
首先我们介绍了广播阈值这一概念它的作用在于当基表尺寸小于广播阈值时Spark SQL将自动选择Broadcast Join策略来完成关联计算。
然后我们分别介绍了AQEAdaptive Query Execution机制的3个特性分别是Join策略调整、自动分区合并、以及自动倾斜处理。与Spark SQL的静态优化机制不同AQE结合Shuffle中间文件提供的统计信息在运行时动态地调整执行计划从而达到优化作业执行性能的目的。
所谓Join策略调整它指的是结合过滤之后的基表尺寸与广播阈值Spark SQL在运行时动态地将原本的Shuffle Join策略调整为Broadcast Join策略的过程。基表尺寸的预估可以使用如下方法来获得。
val df: DataFrame = _
// 先对分布式数据集加Cache
df.cache.count
// 获取执行计划
val plan = df.queryExecution.logical
// 获取执行计划对于数据集大小的精确预估
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
自动分区合并与自动倾斜处理实际上都是用来解决Shuffle过后数据分布不均匀的问题。自动分区合并的作用在于合并过小的数据分区从而避免Task粒度过细、任务调度开销过高的问题。与之相对自动倾斜处理它的用途在于拆分过大的数据分区从而避免个别Task负载过高而拖累整个作业的执行性能。
不论是广播阈值还是AQE的诸多特性我们都可以通过调节相关的配置项来影响Spark SQL的优化行为。为了方便你回顾、查找这些配置项我整理了如下表格供你随时参考。
每课一练
结合AQE必须要依赖Shuffle中间文件这一特点你能说一说AQE有什么不尽如人意之处吗提示从Shuffle的两个计算阶段出发去思考这个问题
欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多的同事、朋友。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 Spark UI如何高效地定位性能问题
你好,我是吴磊。
到目前为止我们完成了基础知识和Spark SQL这两个模块的学习这也就意味着我们完成了Spark入门“三步走”中的前两步首先恭喜你在学习的过程中我们逐渐意识到Spark Core与Spark SQL作为Spark并驾齐驱的执行引擎与优化引擎承载着所有类型的计算负载如批处理、流计算、数据分析、机器学习等等。
那么显然Spark Core与Spark SQL运行得是否稳定与高效决定着Spark作业或是应用的整体“健康状况”。不过在日常的开发工作中我们总会遇到Spark应用运行失败、或是执行效率未达预期的情况。对于这类问题想找到根本原因Root Cause我们往往需要依赖Spark UI来获取最直接、最直观的线索。
如果我们把失败的、或是执行低效的Spark应用看作是“病人”的话那么Spark UI中关于应用的众多度量指标Metrics就是这个病人的“体检报告”。结合多样的Metrics身为“大夫”的开发者即可结合经验来迅速地定位“病灶”。
今天这一讲,让我们以小汽车摇号中“倍率与中签率分析”的应用(详细内容你可以回顾[第13讲]为例用图解的方式一步步地去认识Spark UI看一看它有哪些关键的度量指标这些指标都是什么含义又能为开发者提供哪些洞察Insights
这里需要说明的是Spark UI的讲解涉及到大量的图解、代码与指标释义内容庞杂。因此为了减轻你的学习负担我按照Spark UI的入口类型一级入口、二级入口把Spark UI拆成了上、下两讲。一级入口比较简单、直接我们今天这一讲先来讲解这一部分二级入口的讲解留到下一讲去展开。
准备工作
在正式开始介绍Spark UI之前我们先来简单交代一下图解案例用到的环境、配置与代码。你可以参考这里给出的细节去复现“倍率与中签率分析”案例Spark UI中的每一个界面然后再结合今天的讲解以“看得见、摸得着”的方式去更加直观、深入地熟悉每一个页面与度量指标。
当然如果你手头一时没有合适的执行环境也不要紧。咱们这一讲的特点就是图多后面我特意准备了大量的图片和表格带你彻底了解Spark UI。
由于小汽车摇号数据体量不大,因此在计算资源方面,我们的要求并不高,“倍率与中签率分析”案例用到的资源如下所示:
接下来是代码,在[小汽车摇号应用开发]那一讲,我们一步步地实现了“倍率与中签率分析”的计算逻辑,这里咱们不妨一起回顾一下。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
今天我们在此基础上做一点变化为了方便展示StorageTab页面内容我们这里“强行”给applyNumbersDF 和luckyDogsDF这两个DataFrame都加了Cache。对于引用数量为1的数据集实际上是没有必要加Cache的这一点还需要你注意。
回顾完代码之后再来看看配置项。为了让Spark UI能够展示运行中以及执行完毕的应用我们还需要设置如下配置项并启动History Server。
// SPARK_HOME表示Spark安装目录
${SPAK_HOME}/sbin/start-history-server.sh
好啦到此为止一切准备就绪。接下来让我们启动spark-shell并提交“倍率与中签率分析”的代码然后把目光转移到Host1的8080端口也就是Driver所在节点的8080端口。
Spark UI 一级入口
今天的故事要从Spark UI的入口开始其实刚才说的8080端口正是Spark UI的入口我们可以从这里进入Spark UI。
打开Spark UI首先映入眼帘的是默认的Jobs页面。Jobs页面记录着应用中涉及的Actions动作以及与数据读取、移动有关的动作。其中每一个Action都对应着一个Job而每一个Job都对应着一个作业。我们一会再去对Jobs页面做展开现在先把目光集中在Spark UI最上面的导航条这里罗列着Spark UI所有的一级入口如下图所示。
导航条最左侧是Spark Logo以及版本号后面则依次罗列着6个一级入口每个入口的功能与作用我整理到了如下的表格中你可以先整体过一下后面我们再挨个细讲。
形象点说这6个不同的入口就像是体检报告中6大类不同的体检项比如内科、外科、血常规等等。接下来让我们依次翻开“体检报告”的每一个大项去看看“倍率与中签率分析”这个家伙的体质如何。
不过本着由简入难的原则咱们并不会按照Spark UI罗列的顺序去查看各个入口而是按照Executors > Environment > Storage > SQL > Jobs > Stages的顺序去翻看“体检报告”。
其中前3个入口都是详情页不存在二级入口而后3个入口都是预览页都需要访问二级入口才能获取更加详细的内容。显然相比预览页详情页来得更加直接。接下来让我们从Executors开始先来了解一下应用的计算负载。
Executors
Executors Tab的主要内容如下主要包含“Summary”和“Executors”两部分。这两部分所记录的度量指标是一致的其中“Executors”以更细的粒度记录着每一个Executor的详情而第一部分“Summary”是下面所有Executors度量指标的简单加和。
我们一起来看一下Spark UI都提供了哪些Metrics来量化每一个Executor的工作负载Workload。为了叙述方便我们以表格的形式说明这些Metrics的含义与作用。
不难发现Executors页面清清楚楚地记录着每一个Executor消耗的数据量以及它们对CPU、内存与磁盘等硬件资源的消耗。基于这些信息我们可以轻松判断不同Executors之间是否存在负载不均衡的情况进而判断应用中是否存在数据倾斜的隐患。
对于Executors页面中每一个Metrics的具体数值它们实际上是Tasks执行指标在Executors粒度上的汇总。因此对于这些Metrics的释义咱们留到Stages二级入口再去展开这里暂时不做一一深入。你不妨结合“倍率与中签率分析”的应用去浏览一下不同Metrics的具体数值先对这些数字有一个直观上的感受。
实际上这些具体的数值并没有什么特别之处除了RDD Blocks和Complete Tasks这两个Metrics。细看一下这两个指标你会发现RDD Blocks是51总数而Complete Tasks总数是862。
之前讲RDD并行度的时候我们说过RDD并行度就是RDD的分区数量每个分区对应着一个Task因此RDD并行度与分区数量、分布式任务数量是一致的。可是截图中的51与862显然不在一个量级这是怎么回事呢
这里我先买个关子,把它给你留作思考题,你不妨花些时间,去好好想一想。如果没想清楚也没关系,我们在评论区会继续讨论这个问题。
Environment
接下来我们再来说说Environment。顾名思义Environment页面记录的是各种各样的环境变量与配置项信息如下图所示。
为了让你抓住主线我并没有给你展示Environment页面所包含的全部信息就类别来说它包含5大类环境信息为了方便叙述我把它们罗列到了下面的表格中。
显然这5类信息中Spark Properties是重点其中记录着所有在运行时生效的Spark配置项设置。通过Spark Properties我们可以确认运行时的设置与我们预期的设置是否一致从而排除因配置项设置错误而导致的稳定性或是性能问题。
Storage
说完Executors与Environment我们来看一级入口的最后一个详情页Storage。
Storage详情页记录着每一个分布式缓存RDD Cache、DataFrame Cache的细节包括缓存级别、已缓存的分区数、缓存比例、内存大小与磁盘大小。
在[第8讲]我们介绍过Spark支持的不同缓存级别它是存储介质内存、磁盘、存储形式对象、序列化字节与副本数量的排列组合。对于DataFrame来说默认的级别是单副本的Disk Memory Deserialized如上图所示也就是存储介质为内存加磁盘存储形式为对象的单一副本存储方式。
Cached Partitions与Fraction Cached分别记录着数据集成功缓存的分区数量以及这些缓存的分区占所有分区的比例。当Fraction Cached小于100%的时候,说明分布式数据集并没有完全缓存到内存(或是磁盘),对于这种情况,我们要警惕缓存换入换出可能会带来的性能隐患。
后面的Size in Memory与Size in Disk则更加直观地展示了数据集缓存在内存与硬盘中的分布。从上图中可以看到由于内存受限3GB/Executor摇号数据几乎全部被缓存到了磁盘只有584MB的数据缓存到了内存中。坦白地说这样的缓存对于数据集的重复访问并没有带来实质上的性能收益。
基于Storage页面提供的详细信息我们可以有的放矢地设置与内存有关的配置项如spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction从而有针对性对Storage Memory进行调整。
SQL
接下来我们继续说一级入口的SQL页面。当我们的应用包含DataFrame、Dataset或是SQL的时候Spark UI的SQL页面就会展示相应的内容如下图所示。
具体来说一级入口页面以Actions为单位记录着每个Action对应的Spark SQL执行计划。我们需要点击“Description”列中的超链接才能进入到二级页面去了解每个执行计划的详细信息。这部分内容我们留到下一讲的二级入口详情页再去展开。
Jobs
同理对于Jobs页面来说Spark UI也是以Actions为粒度记录着每个Action对应作业的执行情况。我们想要了解作业详情也必须通过“Description”页面提供的二级入口链接。你先有个初步认识就好下一讲我们再去展开。
相比SQL页面的3个Actionssave保存计算结果、count统计申请编号、count统计中签编号结合前面的概览页截图你会发现Jobs页面似乎凭空多出来很多Actions。
主要原因在于在Jobs页面Spark UI会把数据的读取、访问与移动也看作是一类“Actions”比如图中Job Id为0、1、3、4的那些。这几个Job实际上都是在读取源数据元数据与数据集本身
至于最后多出来的、Job Id为7的save你不妨结合最后一行代码去想想问什么。这里我还是暂时卖个关子留给你足够的时间去思考咱们评论区见。
result05_01.write.mode("Overwrite").format("csv").save(s"${rootPath}/results/result05_01")
Stages
我们知道每一个作业都包含多个阶段也就是我们常说的Stages。在Stages页面Spark UI罗列了应用中涉及的所有Stages这些Stages分属于不同的作业。要想查看哪些Stages隶属于哪个Job还需要从Jobs的Descriptions二级入口进入查看。
Stages页面更多地是一种预览要想查看每一个Stage的详情同样需要从“Description”进入Stage详情页下一讲详细展开
好啦到此为止对于导航条中的不同页面我们都做了不同程度的展开。简单汇总下来其中Executors、Environment、Storage是详情页开发者可以通过这3个页面迅速地了解集群整体的计算负载、运行环境以及数据集缓存的详细情况而SQL、Jobs、Stages更多地是一种罗列式的展示想要了解其中的细节还需要进入到二级入口。
正如开篇所说,二级入口的讲解,我们留到下一讲再去探讨,敬请期待。
重点回顾
好啦今天的课程到这里就讲完啦。今天的内容比较多涉及的Metrics纷繁而又复杂仅仅听一遍我的讲解还远远不够还需要你结合日常的开发去多多摸索与体会加油
今天这一讲我们从简单、直接的一级入口入手按照“Executors -> Environment -> Storage -> SQL -> Jobs -> Stages”的顺序先后介绍了一级入口的详情页与概览页。对于这些页面中的内容我把需要重点掌握的部分整理到了如下表格供你随时参考。
每课一练
今天的思考题我们在课程中已经提过了。一个是在Executors页面为什么RDD Blocks与Complete Tasks的数量不一致。第二个是在Jobs页面为什么最后会多出来一个save Action
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。

View File

@ -0,0 +1,187 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 Spark UI如何高效地定位性能问题
你好,我是吴磊。
在上一讲我们一起梳理了Spark UI的一级入口。其中Executors、Environment、Storage是详情页开发者可以通过这3个页面迅速地了解集群整体的计算负载、运行环境以及数据集缓存的详细情况。不过SQL、Jobs、Stages更多地是一种罗列式的展示想要了解其中的细节还需要进入到二级入口。
沿用之前的比喻,身为“大夫”的开发者想要结合经验,迅速定位“病灶”,离不开各式各样的指标项。而今天要讲的二级入口,相比一级入口,内容更加丰富、详尽。要想成为一名“临床经验丰富”的老医生,咱们先要做到熟练解读这些度量指标。
所谓二级入口它指的是通过一次超链接跳转才能访问到的页面。对于SQL、Jobs和Stages这3类入口来说二级入口往往已经提供了足够的信息基本覆盖了“体检报告”的全部内容。因此尽管Spark UI也提供了少量的三级入口需要两跳才能到达的页面但是这些隐藏在“犄角旮旯”的三级入口往往并不需要开发者去特别关注。
接下来我们就沿着SQL -> Jobs -> Stages的顺序依次地去访问它们的二级入口从而针对全局DAG、作业以及执行阶段获得更加深入的探索与洞察。
SQL详情页
在SQL Tab一级入口我们看到有3个条目分别是count统计申请编号、count统计中签编号和save。前两者的计算过程都是读取数据源、缓存数据并触发缓存的物化相对比较简单因此我们把目光放在save这个条目上。
点击图中的“save at:27”即可进入到该作业的执行计划页面如下图所示。
为了聚焦重点,这里我们仅截取了部分的执行计划,想要获取完整的执行计划,你可以通过访问这里来获得。为了方便你阅读,这里我手绘出了执行计划的示意图,供你参考,如下图所示。
可以看到“倍率与中签率分析”应用的计算过程非常具有代表性它涵盖了数据分析场景中大部分的操作也即过滤、投影、关联、分组聚合和排序。图中红色的部分为Exchange代表的是Shuffle操作蓝色的部分为Sort也就是排序而绿色的部分是Aggregate表示的是局部与全局的数据聚合。
无疑这三部分是硬件资源的主要消费者同时对于这3类操作Spark UI更是提供了详细的Metrics来刻画相应的硬件资源消耗。接下来咱们就重点研究一下这3类操作的度量指标。
Exchange
下图中并列的两个Exchange对应的是示意图中SortMergeJoin之前的两个Exchange。它们的作用是对申请编码数据与中签编码数据做Shuffle为数据关联做准备。
可以看到对于每一个ExchangeSpark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read从数据量到处理时间应有尽有。为了方便说明对于Metrics的解释与释义我以表格的方式进行了整理供你随时查阅。
结合这份Shuffle的“体检报告”我们就能以量化的方式去掌握Shuffle过程的计算细节从而为调优提供更多的洞察与思路。
为了让你获得直观感受我还是举个例子说明。比方说我们观察到过滤之后的中签编号数据大小不足10MB7.4MB这时我们首先会想到对于这样的大表Join小表Spark SQL选择了SortMergeJoin策略是不合理的。
基于这样的判断我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法想必不用我多说你也早已心领神会要么用强制广播要么利用Spark 3.x版本提供的AQE特性。
你不妨结合本讲开头的代码去完成SortMergeJoin到BroadcastHashJoin策略转换的调优期待你在留言区分享你的调优结果。
Sort
接下来我们再来说说Sort。相比ExchangeSort的度量指标没那么多不过他们足以让我们一窥Sort在运行时对于内存的消耗如下图所示。
按照惯例我们还是先把这些Metrics整理到表格中方便后期查看。
可以看到“Peak memory total”和“Spill size total”这两个数值足以指导我们更有针对性地去设置spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction从而使得Execution Memory区域得到充分的保障。
以上图为例结合18.8GB的峰值消耗以及12.5GB的磁盘溢出这两条信息我们可以判断出当前3GB的Executor Memory是远远不够的。那么我们自然要去调整上面的3个参数来加速Sort的执行性能。
Aggregate
与Sort类似衡量Aggregate的度量指标主要记录的也是操作的内存消耗如图所示。
可以看到对于Aggregate操作Spark UI也记录着磁盘溢出与峰值消耗即Spill size和Peak memory total。这两个数值也为内存的调整提供了依据以上图为例零溢出与3.2GB的峰值消耗证明当前3GB的Executor Memory设置对于Aggregate计算来说是绰绰有余的。
到此为止我们分别介绍了Exchange、Sort和Aggregate的度量指标并结合“倍率与中签率分析”的例子进行了简单的调优分析。
纵观“倍率与中签率分析”完整的DAG我们会发现它包含了若干个Exchange、Sort、Aggregate以及Filter和Project。结合上述的各类Metrics对于执行计划的观察与洞见我们需要以统筹的方式由点到线、由局部到全局地去进行。
Jobs详情页
接下来我们再来说说Jobs详情页。Jobs详情页非常的简单、直观它罗列了隶属于当前Job的所有Stages。要想访问每一个Stage的执行细节我们还需要通过“Description”的超链接做跳转。
Stages详情页
实际上要访问Stage详情我们还有另外一种选择那就是直接从Stages一级入口进入然后完成跳转。因此Stage详情页也归类到二级入口。接下来我们以Id为10的Stage为例去看一看详情页都记录着哪些关键信息。
在所有二级入口中Stage详情页的信息量可以说是最大的。点进Stage详情页可以看到它主要包含3大类信息分别是Stage DAG、Event Timeline与Task Metrics。
其中Task Metrics又分为“Summary”与“Entry details”两部分提供不同粒度的信息汇总。而Task Metrics中记录的指标类别还可以通过“Show Additional Metrics”选项进行扩展。
Stage DAG
接下来我们沿着“Stage DAG -> Event Timeline -> Task Metrics”的顺序依次讲讲这些页面所包含的内容。
首先我们先来看最简单的Stage DAG。点开蓝色的“DAG Visualization”按钮我们就能获取到当前Stage的DAG如下图所示。
之所以说Stage DAG简单是因为咱们在SQL二级入口已经对DAG做过详细的说明。而Stage DAG仅仅是SQL页面完整DAG的一个子集毕竟SQL页面的DAG针对的是作业Job。因此只要掌握了作业的DAG自然也就掌握了每一个Stage的DAG。
Event Timeline
与“DAG Visualization”并列在“Summary Metrics”之上有一个“Event Timeline”按钮点开它我们可以得到如下图所示的可视化信息。
Event Timeline记录着分布式任务调度与执行的过程中不同计算环节主要的时间花销。图中的每一个条带都代表着一个分布式任务条带由不同的颜色构成。其中不同颜色的矩形代表不同环节的计算时间。
为了方便叙述,我还是用表格形式帮你梳理了这些环节的含义与作用,你可以保存以后随时查看。
理想情况下,条带的大部分应该都是绿色的(如图中所示),也就是任务的时间消耗,大部分都是执行时间。不过,实际情况并不总是如此,比如,有些时候,蓝色的部分占比较多,或是橙色的部分占比较大。
在这些情况下我们就可以结合Event Timeline来判断作业是否存在调度开销过大、或是Shuffle负载过重的问题从而有针对性地对不同环节做调优。
比方说如果条带中深蓝的部分Scheduler Delay很多那就说明任务的调度开销很重。这个时候我们就需要参考公式D / P ~ M / C来相应地调整CPU、内存与并行度从而减低任务的调度开销。其中D是数据集尺寸P为并行度M是Executor内存而C是Executor的CPU核数。波浪线~表示的是,等式两边的数值,要在同一量级。
再比如如果条带中黄色Shuffle Write Time与橙色Shuffle Read Time的面积较大就说明任务的Shuffle负载很重这个时候我们就需要考虑有没有可能通过利用Broadcast Join来消除Shuffle从而缓解任务的Shuffle负担。
Task Metrics
说完Stage DAG与Event Timeline最后我们再来说一说Stage详情页的重头戏Task Metrics。
之所以说它是重头戏在于Task Metrics以不同的粒度提供了详尽的量化指标。其中“Tasks”以Task为粒度记录着每一个分布式任务的执行细节而“Summary Metrics”则是对于所有Tasks执行细节的统计汇总。我们先来看看粗粒度的“Summary Metrics”然后再去展开细粒度的“Tasks”。
Summary Metrics
首先我们点开“Show Additional Metrics”按钮勾选“Select All”让所有的度量指标都生效如下图所示。这么做的目的在于获取最详尽的Task执行信息。
可以看到“Select All”生效之后Spark UI打印出了所有的执行细节。老规矩为了方便叙述我还是把这些Metrics整理到表格中方便你随时查阅。其中Task Deserialization Time、Result Serialization Time、Getting Result Time、Scheduler Delay与刚刚表格中的含义相同不再赘述这里我们仅整理新出现的Task Metrics。
对于这些详尽的Task Metrics难能可贵地Spark UI以最大最小max、min以及分位点25%分位、50%分位、75%分位的方式提供了不同Metrics的统计分布。这一点非常重要原因在于这些Metrics的统计分布可以让我们非常清晰地量化任务的负载分布。
换句话说根据不同Metrics的统计分布信息我们就可以轻而易举地判定当前作业的不同任务之间是相对均衡还是存在严重的倾斜。如果判定计算负载存在倾斜那么我们就要利用AQE的自动倾斜处理去消除任务之间的不均衡从而改善作业性能。
在上面的表格中有一半的Metrics是与Shuffle直接相关的比如Shuffle Read Size / RecordsShuffle Remote Reads等等。
这些Metrics我们在介绍SQL详情的时候已经详细说过了。另外Duration、GC Time、以及Peak Execution Memory这些Metrics的含义要么已经讲过要么过于简单、无需解释。因此对于这3个指标咱们也不再多着笔墨。
这里特别值得你关注的是SpillMemory和SpillDisk这两个指标。Spill也即溢出数据它指的是因内存数据结构PartitionedPairBuffer、AppendOnlyMap等等空间受限而腾挪出去的数据。SpillMemory表示的是这部分数据在内存中的存储大小而SpillDisk表示的是这些数据在磁盘中的大小。
因此用SpillMemory除以SpillDisk就可以得到“数据膨胀系数”的近似值我们把它记为Explosion ratio。有了Explosion ratio对于一份存储在磁盘中的数据我们就可以估算它在内存中的存储大小从而准确地把握数据的内存消耗。
Tasks
介绍完粗粒度的Summary Metrics接下来我们再来说说细粒度的“Tasks”。实际上Tasks的不少指标与Summary是高度重合的如下图所示。同理这些重合的Metrics咱们不再赘述你可以参考Summary的部分来理解这些Metrics。唯一的区别就是这些指标是针对每一个Task进行度量的。
按照惯例咱们还是把Tasks中那些新出现的指标整理到表格中以备后续查看。
可以看到新指标并不多这里最值得关注的是Locality level也就是本地性级别。在调度系统中我们讲过每个Task都有自己的本地性倾向。结合本地性倾向调度系统会把Tasks调度到合适的Executors或是计算节点尽可能保证“数据不动、代码动”。
Logs与Errors属于Spark UI的三级入口它们是Tasks的执行日志详细记录了Tasks在执行过程中的运行时状态。一般来说我们不需要深入到三级入口去进行Debug。Errors列提供的报错信息往往足以让我们迅速地定位问题所在。
重点回顾
好啦今天的课程到这里就讲完啦。今天这一讲我们分别学习了二级入口的SQL、Jobs与Stages。每个二级入口的内容都很丰富提前知道它们所涵盖的信息对我们寻找、启发与探索性能调优的思路非常有帮助。
到此为止关于Spark UI的全部内容就讲完啦。Spark UI涉及的Metrics纷繁而又复杂一次性记住确实有难度所以通过这一讲你只要清楚各级入口怎么找到知道各个指标能给我们提供什么信息就好了。当然仅仅跟着我去用“肉眼”学习一遍只是第一步之后还需要你结合日常的开发去多多摸索与体会加油
最后的最后还是想提醒你由于我们的应用是通过spark-shell提交的因此节点8080端口的Spark UI会一直展示应用的“体检报告”。在我们退出spark-shell之后节点8080端口的内存也随即消失404 Page not found
要想再次查看应用的“体检报告”需要移步至节点的18080端口这里是Spark History Server的领地它收集了所有已执行完毕应用的“体检报告”并同样使用Spark UI的形式进行展示切记切记。
每课一练
今天的思考题需要你发散思维。学习过Spark UI之后请你说一说都可以通过哪些途径来定位数据倾斜问题
欢迎你把Spark UI使用的心得体会分享到课后的评论区我们一起讨论共同进步也推荐你把这一讲分享更多同事、朋友。

View File

@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Spark MLlib从“房价预测”开始
你好,我是吴磊。
从今天这一讲开始我们进入课程的第三个模块Spark MLlib机器学习。在数据科学、机器学习与人工智能火热的当下积累一些机器学习的知识储备有利于我们拓展视野甚至为职业发展提供新的支点。
在这个模块中我们首先从一个“房价预测”的小项目入手来初步了解机器学习以及Spark MLlib的基本用法。接下来我们会着重讲解机器学习的两个关键环节特征工程与模型调优在深入学习Spark MLlib的同时进一步优化“房价预测”的模型效果从而让房价的预测越来越准。
熟悉了关键环节之后我们再去探讨在Spark MLlib的框架之下高效构建机器学习流水线的一般方法。好啦话不多说让我们先来一起看看“房价预测”这个小项目吧。
为兼顾项目的权威性与代表性这里我选择了Kaggle数据科学竞赛平台的“House Prices - Advanced Regression Techniques”竞赛项目。这个项目的要求是给定房屋的79个属性特征以及历史房价训练房价预测模型并在测试集上验证模型的预测效果。
数据准备
虽然项目的要求相当清晰明了,不过你可能会说:“我没有机器学习背景,上面提到这些什么特征啊、模型啊,还有测试集、效果验证,我都没有概念,那接下来的课程,要怎么学呢?”别担心,随着课程的推进,我会逐渐把这些概念给你讲清楚。
接下来,我们先直观了解一下项目中的房屋数据。
房屋数据记录着美国爱荷华州2006年到2010年的房屋交易数据其中包含着79个房屋属性以及当时的成交价格你可以通过竞赛项目的data页面进行下载。
数据下载、解压之后我们会得到4个文件分别是data_description.txt、train.csv、test.csv和sample_submission.csv。这4个文件的体量很小总大小不超过5MB它们的内容与含义如下表所示。
其中train.csv与test.csv的Schema完全一致都包含79个房屋属性字段以及一个交易价格字段描述文件则详细地记录着79个字段的含义与取值范围。二者的唯一区别在于用途train.csv用于训练模型而test.csv用于验证模型效果。-
sample_submission.csv文件则用于提交比赛结果由于咱们暂时不打算参赛因此这个文件可以暂时忽略。
说到这里,我们又提到了与机器学习有关的一些术语,如“训练数据”、“测试数据”、“模型效果”,等等。为了照顾缺少机器学习背景的同学,接下来,我们对机器做一个简单的介绍。
机器学习简介
不过,在去正式介绍机器学习之前,我们不妨先来想一想人类学习的过程,然后再来看看,在学习这方面,机器与人类有哪些相似之处。
每个人在成长的过程中,或是通过书本,或是结合过往的经历,都在不断地吸取经验教训,从而总结出为人处世、待人接物的一般原则,然后再将这些原则应用到余下的人生中去。人类学习与成长的过程,大抵如此。
实际上,机器学习的过程也是类似的。基于历史数据,机器会根据一定的算法,尝试从历史数据中挖掘并捕捉出一般规律。然后,再把找到的规律应用到新产生的数据中,从而实现在新数据上的预测与判断。
好啦,对于机器学习有了基本的认知之后,接下来, 我们就给它下一个正式的定义,从而以更加严谨的方式,来认识机器学习。
所谓机器学习Machine Learning它指的是这样一种计算过程对于给定的训练数据Training samples选择一种先验的数据分布模型Models然后借助优化算法Learning Algorithms自动地持续调整模型参数Model Weights / Parameters从而让模型不断逼近训练数据的原始分布。
这个持续调整模型参数的过程称为“模型训练”Model Training。模型的训练依赖于优化算法基于过往的计算误差Loss优化算法以不断迭代的方式自动地对模型参数进行调整。由于模型训练是一个持续不断的过程那么自然就需要一个收敛条件Convergence Conditions来终结模型的训练过程。一旦收敛条件触发即宣告模型训练完毕。
模型训练完成之后我们往往会用一份新的数据集Testing samples去测试模型的预测能力从而验证模型的训练效果这个过程我们把它叫作“模型测试”Model Testing
说到这里,你的大脑可能快被各种各样的机器学习术语挤爆了,不要紧,我们结合房价预测的例子,来更好地理解这些概念。
回顾房价预测项目的4个数据文件其中的train.csv就是我们说的训练数据Training samples它用于训练机器学习模型。相应地test.csv是测试数据Testing samples它用于验证我们模型的训练效果。
更严谨地说测试数据用于考察模型的泛化能力Generalization也就是说对于一份模型从来没有“看见过”的数据我们需要知道模型的预测能力与它在训练数据上的表现是否一致。
train.csv和test.csv这两个文件的Schema完全一致都包含81个字段除了其中的79个房屋属性与1个交易价格外还包含一个ID字段。在房价预测这个项目中我们的任务是事先选定一个数据分布模型Models然后在训练数据上对它进行训练Model Training模型参数收敛之后再用训练好的模型去测试集上查看它的训练效果。
房价预测
理论总是没有实战来的更直接接下来我们就来借助Spark MLlib机器学习框架去完成“房价预测”这个机器学习项目的实现。与此同时随着项目的推进我们再结合具体实现来深入理解刚刚提到的基本概念与常用术语。
模型选型
那么都有哪些模型可供我们选择呢?对于房价预测的项目,我们又该选择其中哪一个呢?像这种如何挑选合适模型的问题,我们统一把它称作“模型选型”。
在机器学习领域,模型的种类非常多,不仅如此,模型的分类方法也各有不同。按照拟合能力来分类,有线性模型与非线性模型之分;按照预测标的来划分,有回归、分类、聚类、挖掘之分;按照模型复杂度来区分,模型可以分为经典算法与深度学习;按照模型结构来说,又可以分为广义线性模型、树模型、神经网络,等等。如此种种,不一而足。
不过咱们学习的重点是入门机器学习、入门Spark MLlib因此关于机器学习的模型与算法部分我们留到第24讲再去展开。在这里你只要知道有“模型选型”这回事就可以了。
在“房价预测”这个项目中我们的预测标的Label是房价而房价是连续的数值型字段因此我们需要回归模型Regression Model来拟合数据。再者在所有的模型中线性模型是最简单的因此本着由浅入深的原则在第一版的实现中咱们不妨选定线性回归模型Linear Regression来拟合房价与房屋属性之间的线性关系。
数据探索
要想准确地预测房价,我们得先确定,在与房屋相关的属性中,哪些因素对于房价的影响最大。在模型训练的过程中,我们需要选择那些影响较大的因素,而剔除那些影响较小的干扰项。
结合这里用到的例子,对房价来说,房屋的建筑面积一定是一个很重要的因素。相反,街道的路面类型(水泥路面、沥青路面还是方砖路面),对房价的影响就没那么重要了。
在机器学习领域中与预测标的相关的属性统称为“数据特征”Features而选择有效特征的过程我们称之为“特征选择”Features Selection。在做特性选择之前我们自然免不了先对数据做一番初步的探索才有可能得出结论。
具体的探索过程是这样的。首先我们使用SparkSession的read API从train.csv文件创建DataFrame然后调用show与printSchema函数来观察数据的样本构成与Schema。
由于数据字段较多不方便把打印出的数据样本和Schema堆放在文稿中因此这一步的探索我把它留给你试验你不妨把下面的代码敲入到spark-shell观察一下数据到底“长什么模样”。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
// 从CSV文件创建DataFrame
val trainDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
trainDF.show
trainDF.printSchema
通过观察数据,我们会发现房屋的属性非常丰富,包括诸如房屋建筑面积、居室数量、街道路面情况、房屋类型(公寓还是别墅)、基础设施(水、电、燃气)、生活周边(超市、医院、学校)、地基类型(砖混还是钢混)、地下室面积、地上面积、厨房类型(开放还是封闭)、车库面积与位置、最近一次交易时间,等等。
数据提取
按道理来说要遴选那些对房价影响较大的特征我们需要计算每一个特征与房价之间的相关性。不过在第一版的实现中咱们重点关注Spark MLlib的基本用法暂时不看重模型效果。
所以咱们不妨一切从简只选取那些数值型特征这类特征简单直接适合上手如建筑面积、地上面积、地下室面积和车库面积即”LotArea”“GrLivArea”“TotalBsmtSF”和”GarageArea”如下表所示。严谨的特征选择我们留到下一讲的特征工程再去展开。
import org.apache.spark.sql.types.IntegerType
// 提取用于训练的特征字段与预测标的房价SalePrice
val selectedFields: DataFrame = trainDF.select("LotArea", "GrLivArea", "TotalBsmtSF", "GarageArea", "SalePrice")
// 将所有字段都转换为整型Int
val typedFields = selectedFields
.withColumn("LotAreaInt",col("LotArea").cast(IntegerType)).drop("LotArea")
.withColumn("GrLivAreaInt",col("GrLivArea").cast(IntegerType)).drop("GrLivArea")
.withColumn("TotalBsmtSFInt",col("TotalBsmtSF").cast(IntegerType)).drop("TotalBsmtSF")
.withColumn("GarageAreaInt",col("GarageArea").cast(IntegerType)).drop("GarageArea")
.withColumn("SalePriceInt",col("SalePrice").cast(IntegerType)).drop("SalePrice")
typedFields.printSchema
/** 结果打印
root
|-- LotAreaInt: integer (nullable = true)
|-- GrLivAreaInt: integer (nullable = true)
|-- TotalBsmtSFInt: integer (nullable = true)
|-- GarageAreaInt: integer (nullable = true)
|-- SalePriceInt: integer (nullable = true)
*/
从CSV创建DataFrame所有字段的类型默认都是String而模型在训练的过程中只能消费数值型数据。因此我们这里还要做一下类型转换把所有字段都转换为整型。
准备训练样本
好啦数据准备就绪接下来我们就可以借助Spark MLlib框架开启机器学习的开发之旅。首先第一步我们把准备用于训练的多个特征字段捏合成一个特征向量Feature Vectors如下所示。
import org.apache.spark.ml.feature.VectorAssembler
// 待捏合的特征字段集合
val features: Array[String] = Array("LotAreaInt", "GrLivAreaInt", "TotalBsmtSFInt", "GarageAreaInt")
// 准备“捏合器”,指定输入特征字段集合,与捏合后的特征向量字段名
val assembler = new VectorAssembler().setInputCols(features).setOutputCol("features")
// 调用捏合器的transform函数完成特征向量的捏合
val featuresAdded: DataFrame = assembler.transform(typedFields)
.drop("LotAreaInt")
.drop("GrLivAreaInt")
.drop("TotalBsmtSFInt")
.drop("GarageAreaInt")
featuresAdded.printSchema
/** 结果打印
root
|-- SalePriceInt: integer (nullable = true)
|-- features: vector (nullable = true) // 注意features的字段类型是Vector
*/
捏合完特征向量之后我们就有了用于模型训练的训练样本Training Samples它包含两类数据一类正是特征向量features另一类是预测标的SalePriceInt。
接下来,我们把训练样本成比例地分成两份,一份用于模型训练,剩下的部分用于初步验证模型效果。
val Array(trainSet, testSet) = featuresAdded.randomSplit(Array(0.7, 0.3))
将训练样本拆分为训练集和验证集
模型训练
训练样本准备就绪接下来我们就可以借助Spark MLlib来构建线性回归模型了。实际上使用Spark MLlib构建并训练模型非常简单直接只需3个步骤即可搞定。
第一步是导入相关的模型库在Spark MLlib中线性回归模型由LinearRegression类实现。第二步是创建模型实例并指定模型训练所需的必要信息。第三步是调用模型的fit函数同时提供训练数据集开始训练。
import org.apache.spark.ml.regression.LinearRegression
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
val lr = new LinearRegression()
.setLabelCol("SalePriceInt")
.setFeaturesCol("features")
.setMaxIter(10)
// 使用训练集trainSet训练线性回归模型
val lrModel = lr.fit(trainSet)
可以看到在第二步我们先是创建LinearRegression实例然后通过setLabelCol函数和setFeaturesCol函数来分别指定预测标的字段与特征向量字段也即“SalePriceInt”和“features”。紧接着我们调用setMaxIter函数来指定模型训练的迭代次数。
这里我有必要给你解释一下迭代次数这个概念。在前面介绍机器学习时我们提到模型训练是一个持续不断的过程训练过程会反复扫描同一份数据从而以迭代的方式一次又一次地更新模型中的参数Parameters也叫作权重Weights直到模型的预测效果达到一定的标准才能结束训练。
关于这个标准的制定,来自于两个方面。一方面是对于预测误差的要求,当模型的预测误差小于预先设定的阈值时,模型迭代即可收敛、结束训练。另一个方面就是对于迭代次数的要求,也就是说,不论预测误差是多少,只要达到了预先设定的迭代次数,模型训练即宣告结束。
说到这里,你可能会眉头紧锁:“又出现了些新概念,模型迭代、模型参数,模型的训练到底是一个什么样的过程呢?”为了让你更好地理解模型训练,我来给你举个生活化的例子。
实际上,机器学习中的模型训练,与我们生活中使用微波炉的过程别无二致。假设我们手头上有一款老式的微波炉,微波炉上只有两个旋钮,一个控制温度,另一个控制加热时长。
现在,我们需要烘烤一块馅饼,来当晚饭充饥。晚饭只有一块馅饼,听上去确实是惨了些,不过咱们对于口感的要求还是蛮高的,我们想要得到一块外面焦脆、里面柔嫩的馅饼。
如上图所示对于烹饪经验为0的我们来说想要得到一张烘烤完美的馅饼只能一次次地准备馅饼胚子、一次次把它们送进微波炉然后不断尝试不同的温度与时长组合直到烘焙出外焦里嫩的美味馅饼才会得到最佳的温度与时长组合。
在确定了成功的温度与时长组合之后,当我们需要再次烘烤其他类似食物(比如肉饼、披萨)的时候,就可以把它们送进微波炉,然后直接按下开启键就可以了。
模型训练也是类似的,我们一次次地把训练数据,“喂给”模型算法,一次次地调整模型参数,直到把预测误差降低到一定的范围、或是模型迭代达到一定的次数,即宣告训练结束。当有新的数据需要预测时,我们就把它喂给训练好的模型,模型就能生成预测结果。
不过与我们不停地手动调节“温度”与“时长”旋钮不同模型权重的调整依赖的往往是一种叫作“梯度下降”Gradient Descend的优化算法。在模型的每一次迭代中梯度下降算法会自动地调整模型权重而不需要人为的干预。这个优化算法咱们留到第24讲模型训练那里再展开。
不难发现,在上面馅饼烘焙这个生活化的例子中,相比模型训练,馅饼胚子实际上就是训练数据,微波炉就是模型算法,温度与时长就是模型参数,预测误差就是实际口感与期望口感之间的差距,而尝试的烘焙次数就是迭代次数。关于馅饼烘焙与模型训练的对比,我把它整理到了下图中,你可以看看。
熟悉了与模型训练相关的基本概念之后我们再来回顾一下刚刚的线性回归训练代码。除了表中的3个setXXX函数以外关于模型定义的更多选项你可以参考官网中的开发API来获取完整内容。模型定义好之后我们就可以通过调用fit函数来完成模型的训练过程。
import org.apache.spark.ml.regression.LinearRegression
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
val lr = new LinearRegression()
.setLabelCol("SalePriceInt")
.setFeaturesCol("features")
.setMaxIter(10)
// 使用训练集trainSet训练线性回归模型
val lrModel = lr.fit(trainSet)
模型效果评估
模型训练好之后,我们需要对模型的效果进行验证、评估,才能判定模型的“好”、“坏”。这就好比,馅饼烤熟之后,我们得亲自尝一尝,才能知道它的味道跟我们期待的口感是否一致。
首先我们先来看看模型在训练集上的表现怎么样。在线性回归模型的评估中我们有很多的指标用来量化模型的预测误差。其中最具代表性的要数RMSERoot Mean Squared Error也就是均方根误差。我们可以通过在模型上调用summary函数来获取模型在训练集上的评估指标如下所示。
val trainingSummary = lrModel.summary
println(s"RMSE: ${trainingSummary.rootMeanSquaredError}")
/** 结果打印
RMSE: 45798.86
*/
在训练集的数据分布中房价的值域在34900755000之间因此45798.86的预测误差还是相当大的。这说明我们得到的模型,甚至没有很好地拟合训练数据。换句话说,训练得到的模型,处在一个“欠拟合”的状态。
这其实很好理解,一方面,咱们的模型过于简单,线性回归的拟合能力本身就非常有限。
再者在数据方面我们目前仅仅使用了4个字段LotAreaIntGrLivAreaIntTotalBsmtSFIntGarageAreaInt。房价影响因素众多仅用4个房屋属性是很难准确地预测房价的。所以在后面的几讲中我们还会继续深入研究特征工程与模型选型对于模型拟合能力的影响。
面对这种欠拟合的情况,我们自然还需要进一步调试、优化这个模型。在后续的几讲中,我们会分别从特征工程与模型调优这两个角度出发,去逐步完善我们的“房价预测”模型,拭目以待吧!
重点回顾
今天的内容比较多我们一起来做个总结。今天这一讲我们主要围绕着“房价预测”这个小项目分别介绍了机器学习的基本概念以及如何借助Spark MLlib框架完成机器学习开发。
首先你需要掌握机器学习是怎样的一个计算过程。所谓机器学习Machine Learning它指的是这样一种计算过程。对于给定的训练数据Training samples选择一种先验的数据分布模型Models然后借助优化算法Learning Algorithms自动地持续调整模型参数Model Weights / Parameters从而让模型不断逼近训练数据的原始分布。
然后在Spark MLlib子框架下你需要掌握机器学习开发的基本流程和关键步骤我把这些步骤整理到了如下的表格中方便你随时回顾。
今天这一讲我们采用了“机器学习基础知识”与“Spark MLlib开发流程”相交叉的方式来同时讲解机器学习本身与Spark MLlib子框架。对于机器学习背景较为薄弱的同学来说学习今天的内容可能有些挑战。
不过,你不用担心,对于本讲中挖下的“坑”,我们在后续的几讲中,都会陆续补上,力争让你系统掌握机器学习的开发方法与常规套路。
每日一练
请按照这一讲的行文顺序整理从加载数据到模型训练、模型评估的所有代码。然后请你从Kaggle数据科学竞赛平台的“House Prices - Advanced Regression Techniques”竞赛项目下载训练数据完成从数据加载到模型训练的整个过程。
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友,一起动手试试从数据加载到模型训练的整个过程。

View File

@ -0,0 +1,369 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 特征工程(上):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲,我们一起构建了一个简单的线性回归模型,来预测美国爱荷华州的房价。从模型效果来看,模型的预测能力非常差。不过,事出有因,一方面线性回归的拟合能力有限,再者,我们使用的特征也是少的可怜。
要想提升模型效果,具体到我们“房价预测”的案例里就是把房价预测得更准,我们需要从特征和模型两个方面着手,逐步对模型进行优化。
在机器学习领域有一条尽人皆知的“潜规则”Garbage ingarbage out。它的意思是说当我们喂给模型的数据是“垃圾”的时候模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话实际上它指的是不完善的特征工程。
特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。
作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。
为了减轻你的学习负担我把特征工程拆成了上、下两篇。我会用两讲的内容带你了解在Spark MLlib的开发框架下都有哪些完善特征工程的方法。总的来说我们需要学习6大类特征处理方法今天这一讲我们先来学习前3类下一讲再学习另外3类。
课程安排
打开Spark MLlib特征工程页面你会发现这里罗列着数不清的特征处理函数让人眼花缭乱。作为初学者看到这么长的列表更是会感到无所适从。
不过,你别担心,对于列表中的函数,结合过往的应用经验,我会从特征工程的视角出发,把它们分门别类地进行归类。
如图所示从原始数据生成可用于模型训练的训练样本这个过程又叫“特征工程”我们有很长的路要走。通常来说对于原始数据中的字段我们会把它们分为数值型Numeric和非数值型Categorical。之所以要这样区分原因在于字段类型不同处理方法也不同。
在上图中从左到右Spark MLlib特征处理函数可以被分为如下几类依次是
预处理
特征选择
归一化
离散化
Embedding
向量计算
除此之外Spark MLlib还提供了一些用于自然语言处理NLPNatural Language Processing的初级函数如图中左上角的虚线框所示。作为入门课这部分不是咱们今天的重点如果你对NLP感兴趣的话可以到官网页面了解详情。
我会从每个分类里各挑选一个最具代表性的函数(上图中字体加粗的函数),结合“房价预测”项目为你深入讲解。至于其他的处理函数,跟同一分类中我们讲到的函数其实是大同小异的。所以,只要你耐心跟着我学完这部分内容,自己再结合官网进一步探索其他处理函数时,也会事半功倍。
特征工程
接下来咱们就来结合上一讲的“房价预测”项目去探索Spark MLlib丰富而又强大的特征处理函数。
在上一讲我们的模型只用到了4个特征分别是”LotArea”“GrLivArea”“TotalBsmtSF”和”GarageArea”。选定这4个特征去建模意味着我们做了一个很强的先验假设房屋价格仅与这4个房屋属性有关。显然这样的假设并不合理。作为消费者在决定要不要买房的时候绝不会仅仅参考这4个房屋属性。
爱荷华州房价数据提供了多达79个房屋属性其中一部分是数值型字段如记录各种尺寸、面积、大小、数量的房屋属性另一部分是非数值型字段比如房屋类型、街道类型、建筑日期、地基类型等等。
显然房价是由这79个属性当中的多个属性共同决定的。机器学习的任务就是先找出这些“决定性”因素房屋属性然后再用一个权重向量模型参数来量化不同因素对于房价的影响。
预处理StringIndexer
由于绝大多数模型(包括线性回归模型)都不能直接“消费”非数值型数据,因此,咱们的第一步,就是把房屋属性中的非数值字段,转换为数值字段。在特征工程中,对于这类基础的数据转换操作,我们统一把它称为预处理。
我们可以利用Spark MLlib提供的StringIndexer完成预处理。顾名思义StringIndexer的作用是以数据列为单位把字段中的字符串转换为数值索引。例如使用StringIndexer我们可以把“车库类型”属性GarageType中的字符串转换为数字如下图所示。
StringIndexer的用法比较简单可以分为三个步骤
第一步实例化StringIndexer对象
第二步通过setInputCol和setOutputCol来指定输入列和输出列
第三步调用fit和transform函数完成数据转换。
接下来我们就结合上一讲的“房价预测”项目使用StringIndexer对所有的非数值字段进行转换从而演示并学习它的用法。
首先我们读取房屋源数据并创建DataFrame。
import org.apache.spark.sql.DataFrame
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
val sourceDataDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
然后我们挑选出所有的非数值字段并使用StringIndexer对其进行转换。
// 导入StringIndexer
import org.apache.spark.ml.feature.StringIndexer
// 所有非数值型字段也即StringIndexer所需的“输入列”
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
// 非数值字段对应的目标索引字段也即StringIndexer所需的“输出列”
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 将engineeringDF定义为var变量后续所有的特征工程都作用在这个DataFrame之上
var engineeringDF: DataFrame = sourceDataDF
// 核心代码循环遍历所有非数值字段依次定义StringIndexer完成字符串到数值索引的转换
for ((field, indexField) <- categoricalFields.zip(indexFields)) {
// 定义StringIndexer指定输入列名、输出列名
val indexer = new StringIndexer()
.setInputCol(field)
.setOutputCol(indexField)
// 使用StringIndexer对原始数据做转换
engineeringDF = indexer.fit(engineeringDF).transform(engineeringDF)
// 删除掉原始的非数值字段列
engineeringDF = engineeringDF.drop(field)
}
尽管代码看上去很多但我们只需关注与StringIndexer有关的部分即可。我们刚刚介绍了StringIndexer用法的三个步骤咱们不妨把这些步骤和上面的代码对应起来这样可以更加直观地了解StringIndexer的具体用法。
以“车库类型”GarageType字段为例我们先初始化一个StringIndexer实例。然后把GarageType传入给它的setInputCol函数。接着把GarageTypeIndex传入给它的setOutputCol函数。
注意GarageType是原始字段也就是engineeringDF这个DataFrame中原本就包含的数据列而GarageTypeIndex是StringIndexer即将生成的数据列目前的engineeringDF暂时还不包含这个字段。
最后我们在StringIndexer之上依次调用fit和transform函数来生成输出列这两个函数的参数都是待转换的DataFrame在我们的例子中这个DataFrame是engineeringDF。
转换完成之后你会发现engineeringDF中多了一个新的数据列也就是GarageTypeIndex这个字段。而这一列包含的数据内容就是与GarageType数据列对应的数值索引如下所示。
engineeringDF.select("GarageType", "GarageTypeIndex").show(5)
/** 结果打印
+----------+---------------+
|GarageType|GarageTypeIndex|
+----------+---------------+
| Attchd| 0.0|
| Attchd| 0.0|
| Attchd| 0.0|
| Detchd| 1.0|
| Attchd| 0.0|
+----------+---------------+
only showing top 5 rows
*/
可以看到转换之后GarageType字段中所有的“Attchd”都被映射为0而所有“Detchd”都被转换为1。实际上剩余的“CarPort”、“BuiltIn”等字符串也都被转换成了对应的索引值。
为了对DataFrame中所有的非数值字段都进行类似的处理我们使用for循环来进行遍历你不妨亲自动手去尝试运行上面的完整代码并进一步验证除GarageType以外的其他字段的转换也是符合预期的。
好啦到此为止我们以StringIndexer为例跑通了Spark MLlib的预处理环节拿下了特征工程的第一关恭喜你接下来我们再接再厉一起去挑战第二道关卡特征选择。
特征选择ChiSqSelector
特征选择,顾名思义,就是依据一定的标准,对特征字段进行遴选。
以房屋数据为例它包含了79个属性字段。在这79个属性当中不同的属性对于房价的影响程度是不一样的。显然像房龄、居室数量这类特征远比供暖方式要重要得多。特征选择就是遴选出像房龄、居室数量这样的关键特征然后进行建模而抛弃对预测标的房价无足轻重的供暖方式。
不难发现,在刚刚的例子中,我们是根据日常生活经验作为遴选特征字段的标准。实际上,面对数量众多的候选特征,业务经验往往是特征选择的重要出发点之一。在互联网的搜索、推荐与广告等业务场景中,我们都会尊重产品经理与业务专家的经验,结合他们的反馈来初步筛选出候选特征集。
与此同时,我们还会使用一些统计方法,去计算候选特征与预测标的之间的关联性,从而以量化的方式,衡量不同特征对于预测标的重要性。
统计方法在验证专家经验有效性的同时,还能够与之形成互补,因此,在日常做特征工程的时候,我们往往将两者结合去做特征选择。
业务经验因场景而异无法概述因此咱们重点来说一说可以量化的统计方法。统计方法的原理并不复杂本质上都是基于不同的算法如Pearson系数、卡方分布来计算候选特征与预测标的之间的关联性。不过你可能会问“我并不是统计学专业的做特征选择是不是还要先去学习这些统计方法呢
别担心其实并不需要。Spark MLlib框架为我们提供了多种特征选择器Selectors这些Selectors封装了不同的统计方法。要做好特征选择我们只需要搞懂Selectors该怎么用而不必纠结它背后使用的到底是哪些统计方法。
以ChiSqSelector为例它所封装的统计方法是卡方检验与卡方分布。即使你暂时还不清楚卡方检验的工作原理也并不影响我们使用ChiSqSelector来轻松完成特征选择。
接下来咱们还是以“房价预测”的项目为例说一说ChiSqSelector的用法与注意事项。既然是量化方法这就意味着Spark MLlib的Selectors只能用于数值型字段。要使用ChiSqSelector来选择数值型字段我们需要完成两步走
第一步使用VectorAssembler创建特征向量
第二步基于特征向量使用ChiSqSelector完成特征选择。
VectorAssembler原本属于特征工程中向量计算的范畴不过在Spark MLlib框架内很多特征处理函数的输入参数都是特性向量Feature Vector比如现在要讲的ChiSqSelector。因此这里我们先要对VectorAssembler做一个简单的介绍。
VectorAssembler的作用是把多个数值列捏合为一个特征向量。以房屋数据的三个数值列“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”为例VectorAssembler可以把它们捏合为一个新的向量字段如下图所示。
VectorAssembler的用法很简单初始化VectorAssembler实例之后调用setInputCols传入待转换的数值字段列表如上图中的3个字段使用setOutputCol函数来指定待生成的特性向量字段如上图中的“features”字段。接下来我们结合代码来演示VectorAssembler的具体用法。
// 所有数值型字段共有27个
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// 预测标的字段
val labelFields: Array[String] = Array("SalePrice")
import org.apache.spark.sql.types.IntegerType
// 将所有数值型字段转换为整型Int
for (field <- (numericFields ++ labelFields)) {
engineeringDF = engineeringDF.withColumn(s"${field}Int",col(field).cast(IntegerType)).drop(field)
}
import org.apache.spark.ml.feature.VectorAssembler
// 所有类型为Int的数值型字段
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
// 定义并初始化VectorAssembler
val assembler = new VectorAssembler()
.setInputCols(numericFeatures)
.setOutputCol("features")
// 在DataFrame应用VectorAssembler生成特征向量字段"features"
engineeringDF = assembler.transform(engineeringDF)
代码内容较多我们把目光集中到最下面的两行。首先我们定义并初始化VectorAssembler实例将包含有全部数值字段的数组numericFeatures传入给setInputCols函数并使用setOutputCol函数指定输出列名为“features”。然后通过调用VectorAssembler的transform函数完成对engineeringDF的转换。
转换完成之后engineeringDF就包含了一个字段名为“features”的数据列它的数据内容就是拼接了所有数值特征的特征向量。
好啦,特征向量准备完毕之后,我们就可以基于它来做特征选择了。还是先上代码。
import org.apache.spark.ml.feature.ChiSqSelector
import org.apache.spark.ml.feature.ChiSqSelectorModel
// 定义并初始化ChiSqSelector
val selector = new ChiSqSelector()
.setFeaturesCol("features")
.setLabelCol("SalePriceInt")
.setNumTopFeatures(20)
// 调用fit函数在DataFrame之上完成卡方检验
val chiSquareModel = selector.fit(engineeringDF)
// 获取ChiSqSelector选取出来的入选特征集合索引
val indexs: Array[Int] = chiSquareModel.selectedFeatures
import scala.collection.mutable.ArrayBuffer
val selectedFeatures: ArrayBuffer[String] = ArrayBuffer[String]()
// 根据特征索引值,查找数据列的原始字段名
for (index <- indexs) {
selectedFeatures += numericFields(index)
}
首先我们定义并初始化ChiSqSelector实例分别通过setFeaturesCol和setLabelCol来指定特征向量和预测标的。毕竟ChiSqSelector所封装的卡方检验需要将特征与预测标的进行关联才能量化每一个特征的重要性。
接下来对于全部的27个数值特征我们需要告诉ChiSqSelector要从中选出多少个进行建模。这里我们传递给setNumTopFeatures的参数是20也就是说ChiSqSelector需要帮我们从27个特征中挑选出对房价影响最重要的前20个特征。
ChiSqSelector实例创建完成之后我们通过调用fit函数对engineeringDF进行卡方检验得到卡方检验模型chiSquareModel。访问chiSquareModel的selectedFeatures变量即可获得入选特征的索引值再结合原始的数值字段数组我们就可以得到入选的原始数据列。
听到这里你可能已经有点懵了不要紧结合下面的示意图你可以更加直观地熟悉ChiSqSelector的工作流程。这里我们还是以“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”这3个字段为例来进行演示。
可以看到对房价来说ChiSqSelector认为前两个字段比较重要而厨房个数没那么重要。因此在selectedFeatures这个数组中ChiSqSelector记录了0和1这两个索引分别对应着原始的“LotFrontage”和“BedroomAbvGr”这两个字段。
好啦到此为止我们以ChiSqSelector为代表学习了Spark MLlib框架中特征选择的用法打通了特征工程的第二关。接下来我们继续努力去挑战第三道关卡归一化。
归一化MinMaxScaler
归一化Normalization的作用是把一组数值统一映射到同一个值域而这个值域通常是[0, 1]。也就是说不管原始数据序列的量级是105还是10-5归一化都会把它们统一缩放到[0, 1]这个范围。
这么说可能比较抽象我们拿“LotArea”、“BedroomAbvGr”这两个字段来举例。其中“LotArea”的含义是房屋面积它的单位是平方英尺量级在105而“BedroomAbvGr”的单位是个数它的量级是101。
假设我们采用Spark MLlib提供的MinMaxScaler对房屋数据做归一化那么这两列数据都会被统一缩放到[0, 1]这个值域范围,从而抹去单位不同带来的量纲差异。
你可能会问:“为什么要做归一化呢?去掉量纲差异的动机是什么呢?原始数据它不香吗?”
原始数据很香,但原始数据的量纲差异不香。当原始数据之间的量纲差异较大时,在模型训练的过程中,梯度下降不稳定、抖动较大,模型不容易收敛,从而导致训练效率较差。相反,当所有特征数据都被约束到同一个值域时,模型训练的效率会得到大幅提升。关于模型训练与模型调优,我们留到下一讲再去展开,这里你先理解归一化的必要性即可。
既然归一化这么重要,那具体应该怎么实现呢?其实很简单,只要一个函数就可以搞定。
Spark MLlib支持多种多样的归一化函数如StandardScaler、MinMaxScaler等等。尽管这些函数的算法各有不同但效果都是一样的。
我们以MinMaxScaler为例看一看对于任意的房屋面积eiMinMaxScaler使用如下公式来完成对“LotArea”字段的归一化。
其中max和min分别是目标值域的上下限默认为1和0换句话说目标值域为[0, 1]。而Emax和Emin分别是“LotArea”这个数据列中的最大值和最小值。使用这个公式MinMaxScaler就会把“LotArea”中所有的数值都映射到[0, 1]这个范围。
接下来我们结合代码来演示MinMaxScaler的具体用法。
与很多特征处理函数如刚刚讲过的ChiSqSelector一样MinMaxScaler的输入参数也是特征向量因此MinMaxScaler的用法也分为两步走
第一步使用VectorAssembler创建特征向量
第二步基于特征向量使用MinMaxScaler完成归一化。
// 所有类型为Int的数值型字段
// val numericFeatures: Array[String] = numericFields.map(_ + “Int”).toArray
// 遍历每一个数值型字段
for (field <- numericFeatures) {
// 定义并初始化VectorAssembler
val assembler = new VectorAssembler()
.setInputCols(Array(field))
.setOutputCol(s”${field}Vector”)
// 调用transform把每个字段由Int转换为Vector类型
engineeringData = assembler.transform(engineeringData)
}
在第一步我们使用for循环遍历所有数值型字段依次初始化VectorAssembler实例把字段由Int类型转为Vector向量类型。接下来在第二步我们就可以把所有向量传递给MinMaxScaler去做归一化了。可以看到MinMaxScaler的用法与StringIndexer的用法很相似。
import org.apache.spark.ml.feature.MinMaxScaler
// 锁定所有Vector数据列
val vectorFields: Array[String] = numericFeatures.map(_ + "Vector").toArray
// 归一化后的数据列
val scaledFields: Array[String] = vectorFields.map(_ + "Scaled").toArray
// 循环遍历所有Vector数据列
for (vector <- vectorFields) {
// 定义并初始化MinMaxScaler
val minMaxScaler = new MinMaxScaler()
.setInputCol(vector)
.setOutputCol(s"${vector}Scaled")
// 使用MinMaxScaler完成Vector数据列的归一化
engineeringData = minMaxScaler.fit(engineeringData).transform(engineeringData)
}
首先我们创建一个MinMaxScaler实例然后分别把原始Vector数据列和归一化之后的数据列传递给函数setInputCol和setOutputCol。接下来依次调用fit与transform函数完成对目标字段的归一化。
这段代码执行完毕之后engineeringDataDataFrame就包含了多个后缀为“Scaled”的数据列这些数据列的内容就是对应原始字段的归一化数据如下所示。
好啦到此为止我们以MinMaxScaler为代表学习了Spark MLlib框架中数据归一化的用法打通了特征工程的第三关。
重点回顾
好啦,今天的内容讲完啦,我们一起来做个总结。今天这一讲,我们主要围绕特征工程展开,你需要掌握特征工程不同环节的特征处理方法,尤其是那些最具代表性的特征处理函数。
从原始数据到生成训练样本,特征工程可以被分为如下几个环节,我们今天重点讲解了其中的前三个环节,也就是预处理、特征选择和归一化。
针对不同环节Spark MLlib框架提供了丰富的特征处理函数。作为预处理环节的代表StringIndexer负责对非数值型特征做初步处理将模型无法直接消费的字符串转换为数值。
特征选择的动机,在于提取与预测标的关联度更高的特征,从而精简模型尺寸、提升模型泛化能力。特征选择可以从两方面入手,业务出发的专家经验和基于数据的统计分析。
Spark MLlib基于不同的统计方法提供了多样的特征选择器Feature Selectors其中ChiSqSelector以卡方检验为基础选择相关度最高的前N个特征。
归一化的目的在于去掉不同特征之间量纲的影响避免量纲不一致而导致的梯度下降震荡、模型收敛效率低下等问题。归一化的具体做法是把不同特征都缩放到同一个值域。在这方面Spark MLlib提供了多种归一化方法供开发者选择。
在下一讲我们将继续离散化、Embedding和向量计算这3个环节的学习最后还会带你整体看一下各环节优化过后的模型效果敬请期待。
每课一练
对于我们今天讲解的特征处理函数如StringIndexer、ChiSqSelector、MinMaxScaler你能说说它们之间的区别和共同点吗
欢迎你在留言区跟我交流互动,也推荐你把今天的内容转发给更多同事和朋友,跟他一起交流特征工程相关的内容。

View File

@ -0,0 +1,231 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 特征工程(下):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲我们提到典型的特征工程包含如下几个环节即预处理、特征选择、归一化、离散化、Embedding和向量计算如下图所示。
在上一讲我们着重讲解了其中的前3个环节也就是预处理、特征选择和归一化。按照之前的课程安排今天这一讲咱们继续来说说剩下的离散化、Embedding与向量计算。
特征工程是机器学习的重中之重只要你耐心学下去必然会不虚此行。这一讲的最后我还会对应用了6种不同特征工程的模型性能加以对比帮你深入理解特征工程中不同环节的作用与效果。
特征工程
在上一讲,我们打卡到了“第三关”:归一化。因此,接下来,我们先从“第四关”:离散化说起。
离散化Bucketizer
与归一化一样离散化也是用来处理数值型字段的。离散化可以把原本连续的数值打散从而降低原始数据的多样性Cardinality。举例来说“BedroomAbvGr”字段的含义是居室数量在train.csv这份数据样本中“BedroomAbvGr”包含从1到8的连续整数。
现在,我们根据居室数量,把房屋粗略地划分为小户型、中户型和大户型。
不难发现“BedroomAbvGr”离散化之后数据多样性由原来的8降低为现在的3。那么问题来了原始的连续数据好好的为什么要对它做离散化呢离散化的动机主要在于提升特征数据的区分度与内聚性从而与预测标的产生更强的关联。
就拿“BedroomAbvGr”来说我们认为一居室和两居室对于房价的影响差别不大同样三居室和四居室之间对于房价的影响也是微乎其微。
但是小户型与中户型之间以及中户型与大户型之间房价往往会出现跃迁的现象。换句话说相比居室数量户型的差异对于房价的影响更大、区分度更高。因此把“BedroomAbvGr”做离散化处理目的在于提升它与预测标的之间的关联性。
那么在Spark MLlib的框架下离散化具体该怎么做呢与其他环节一样Spark MLlib提供了多个离散化函数比如Binarizer、Bucketizer和QuantileDiscretizer。我们不妨以Bucketizer为代表结合居室数量“BedroomAbvGr”这个字段来演示离散化的具体用法。老规矩还是先上代码为敬。
// 原始字段
val fieldBedroom: String = "BedroomAbvGrInt"
// 包含离散化数据的目标字段
val fieldBedroomDiscrete: String = "BedroomDiscrete"
// 指定离散区间,分别是[负无穷, 2]、[3, 4]和[5, 正无穷]
val splits: Array[Double] = Array(Double.NegativeInfinity, 3, 5, Double.PositiveInfinity)
import org.apache.spark.ml.feature.Bucketizer
// 定义并初始化Bucketizer
val bucketizer = new Bucketizer()
// 指定原始列
.setInputCol(fieldBedroom)
// 指定目标列
.setOutputCol(fieldBedroomDiscrete)
// 指定离散区间
.setSplits(splits)
// 调用transform完成离散化转换
engineeringData = bucketizer.transform(engineeringData)
不难发现Spark MLlib提供的特征处理函数在用法上大同小异。首先我们创建Bucketizer实例然后将数值型字段BedroomAbvGrInt作为参数传入setInputCol同时使用setOutputCol来指定用于保存离散数据的新字段BedroomDiscrete。
离散化的过程是把连续值打散为离散值但具体的离散区间如何划分还需要我们通过在setSplits里指定。离散区间由浮点型数组splits提供从负无穷到正无穷划分出了[负无穷, 2]、[3, 4]和[5, 正无穷]这三个区间。最终我们调用Bucketizer的transform函数对engineeringData做离散化。
离散化前后的数据对比,如下图所示。
好啦到此为止我们以Bucketizer为代表学习了Spark MLlib框架中数据离散化的用法轻松打通了特征工程的第四关。
Embedding
实际上Embedding是一个非常大的话题随着机器学习与人工智能的发展Embedding的方法也是日新月异、层出不穷。从最基本的热独编码到PCA降维从Word2Vec到Item2Vec从矩阵分解到基于深度学习的协同过滤可谓百花齐放、百家争鸣。更有学者提出“万物皆可Embedding”。那么问题来了什么是Embedding呢
Embedding是个英文术语如果非要找一个中文翻译对照的话我觉得“向量化”Vectorize最合适。Embedding的过程就是把数据集合映射到向量空间进而把数据进行向量化的过程。这句话听上去有些玄乎我换个更好懂的说法Embedding的目标就是找到一组合适的向量来刻画现有的数据集合。
以GarageType字段为例它有6个取值也就是说我们总共有6种车库类型。那么对于这6个字符串来说我们该如何用数字化的方式来表示它们呢毕竟模型只能消费数值不能直接消费字符串。
一种方法是采用预处理环节的StringIndexer把字符串转换为连续的整数然后让模型去消费这些整数。在理论上这么做没有任何问题。但从模型的效果出发整数的表达方式并不合理。为什么这么说呢
我们知道连续整数之间是存在比较关系的比如1 < 36 > 5等等。但是原始的字符串之间比如“Attchd”与“Detchd”并不存在大小关系如果强行用0表示“Attchd”、用1表示“Detchd”逻辑上就会出现“Attchd”<“Detchd”的悖论。
因此预处理环节的StringIndexer仅仅是把字符串转换为数字转换得到的数值是不能直接喂给模型做训练。我们需要把这些数字进一步向量化才能交给模型去消费。那么问题来了对于StringIndexer输出的数值我们该怎么对他们进行向量化呢这就要用到Embedding了。
作为入门课咱们不妨从最简单的热独编码One Hot Encoding开始去认识Embedding并掌握它的基本用法。我们先来说说热独编码是怎么一回事。相比照本宣科说概念咱们不妨以GarageType为例从示例入手你反而更容易心领神会。
首先通过StringIndexer我们把GarageType的6个取值分别映射为0到5的六个数值。接下来使用热独编码我们把每一个数值都转化为一个向量。
向量的维度为6与原始字段GarageType的多样性Cardinality保持一致。换句话说热独编码的向量维度就是原始字段的取值个数。
仔细观察上图的六个向量只有一个维度取值为1其他维度全部为0。取值为1的维度与StringIndexer输出的索引相一致。举例来说字符串“Attchd”被StringIndexer映射为0对应的热独向量是[1, 0, 0, 0, 0, 0]。向量中索引为0的维度取值为1其他维度全部取0。
不难发现热独编码是一种简单直接的Embedding方法甚至可以说是“简单粗暴”。不过在日常的机器学习开发中“简单粗暴”的热独编码却颇受欢迎。
接下来,我们还是从“房价预测”的项目出发,说一说热独编码的具体用法。
在预处理环节我们已经用StringIndexer把非数值字段全部转换为索引字段接下来我们再用OneHotEncoder把索引字段进一步转换为向量字段。
import org.apache.spark.ml.feature.OneHotEncoder
// 非数值字段对应的目标索引字段也即StringIndexer所需的“输出列”
// val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 热独编码的目标字段也即OneHotEncoder所需的“输出列”
val oheFields: Array[String] = categoricalFields.map(_ + "OHE").toArray
// 循环遍历所有索引字段,对其进行热独编码
for ((indexField, oheField) <- indexFields.zip(oheFields)) {
val oheEncoder = new OneHotEncoder()
.setInputCol(indexField)
.setOutputCol(oheField)
engineeringData= oheEncoder.transform(engineeringData)
}
可以看到我们循环遍历所有非数值特征依次创建OneHotEncoder实例。在实例初始化的过程中我们把索引字段传入给setInputCol函数把热独编码目标字段传递给setOutputCol函数。最终通过调用OneHotEncoder的transform在engineeringData之上完成转换。
好啦到此为止我们以OneHotEncoder为代表学习了Spark MLlib框架中Embedding的用法初步打通了特征工程的第五关。
尽管还有很多其他Embedding方法需要我们进一步探索不过从入门的角度来说OneHotEncoder完全可以应对大部分机器学习应用。
向量计算
打通第五关之后,特征工程“这套游戏”还剩下最后一道关卡:向量计算。
向量计算作为特征工程的最后一个环节主要用于构建训练样本中的特征向量Feature Vectors。在Spark MLlib框架下训练样本由两部分构成第一部分是预测标的Label在“房价预测”的项目中Label是房价。
而第二部分就是特征向量在形式上特征向量可以看作是元素类型为Double的数组。根据前面的特征工程流程图我们不难发现特征向量的构成来源多种多样比如原始的数值字段、归一化或是离散化之后的数值字段、以及向量化之后的特征字段等等。
Spark MLlib在向量计算方面提供了丰富的支持比如前面介绍过的、用于集成特征向量的VectorAssembler用于对向量做剪裁的VectorSlicer以元素为单位做乘法的ElementwiseProduct等等。灵活地运用这些函数我们可以随意地组装特征向量从而构建模型所需的训练样本。
在前面的几个环节中预处理、特征选择、归一化、离散化、Embedding我们尝试对数值和非数值类型特征做各式各样的转换目的在于探索可能对预测标的影响更大的潜在因素。
接下来我们使用VectorAssembler将这些潜在因素全部拼接在一起、构建特征向量从而为后续的模型训练准备好训练样本。
import org.apache.spark.ml.feature.VectorAssembler
/**
入选的数值特征selectedFeatures
归一化的数值特征scaledFields
离散化的数值特征fieldBedroomDiscrete
热独编码的非数值特征oheFields
*/
val assembler = new VectorAssembler()
.setInputCols(selectedFeatures ++ scaledFields ++ fieldBedroomDiscrete ++ oheFields)
.setOutputCol("features")
engineeringData = assembler.transform(engineeringData)
转换完成之后engineeringData这个DataFrame就包含了一列名为“features”的新字段这个字段的内容就是每条训练样本的特征向量。接下来我们就可以像上一讲那样通过setFeaturesCol和setLabelCol来指定特征向量与预测标的定义出线性回归模型。
// 定义线性回归模型
val lr = new LinearRegression()
.setFeaturesCol("features")
.setLabelCol("SalePriceInt")
.setMaxIter(100)
// 训练模型
val lrModel = lr.fit(engineeringData)
// 获取训练状态
val trainingSummary = lrModel.summary
// 获取训练集之上的预测误差
println(s"Root Mean Squared Error (RMSE) on train data: ${trainingSummary.rootMeanSquaredError}")
好啦,到此为止,我们打通了特征工程所有关卡,恭喜你!尽管不少关卡还有待我们进一步去深入探索,但这并不影响我们从整体上把握特征工程,构建结构化的知识体系。对于没讲到的函数与技巧,你完全可以利用自己的碎片时间,借鉴这两节课我给你梳理的学习思路,来慢慢地将它们补齐,加油!
通关奖励:模型效果对比
学习过VectorAssembler的用法之后你会发现特征工程任一环节的输出都可以用来构建特征向量从而用于模型训练。在介绍特征工程的部分我们花了大量篇幅介绍不同环节的作用与用法。
你可能会好奇:“这些不同环节的特征处理,真的会对模型效果有帮助吗?毕竟,折腾了半天,我们还是要看模型效果的”。
没错,特征工程的最终目的,是调优模型效果。接下来,通过将不同环节输出的训练样本喂给模型,我们来对比不同特征处理方法对应的模型效果。
不同环节对应的代码地址如下:
调优对比基准-
特征工程-调优1-
特征工程-调优2-
特征工程-调优3-
特征工程-调优4-
特征工程-调优5-
特征工程-调优6
可以看到,随着特征工程的推进,模型在训练集上的预测误差越来越小,这说明模型的拟合能力越来越强,而这也就意味着,特征工程确实有助于模型性能的提升。
对应特征工程不同环节的训练代码,我整理到了最后的“代码地址”那一列。强烈建议你动手运行这些代码,对比不同环节的特征处理方法,以及对应的模型效果。
当然,我们在评估模型效果的时候,不能仅仅关注它的拟合能力,更重要的是模型的泛化能力。拟合能力强,只能说明模型在训练集上的预测误差足够小;而泛化能力,量化的是模型在测试集上的预测误差。换句话说,泛化能力的含义是,模型在一份“未曾谋面”的数据集上表现如何。
这一讲,咱们的重点是特征工程,因此暂时忽略了模型在测试集上的表现。从下一讲的模型训练开始,对于模型效果,我们将同时关注模型这两方面的能力:拟合与泛化。
重点回顾
好啦今天的内容讲完啦我们一起来做个总结。今天这一讲我们主要围绕着特征工程中的离散化、Embedding和向量计算展开你需要掌握其中最具代表性的特征处理函数。
到此为止Spark MLlib特征工程中涉及的6大类特征处理函数我们就都讲完了。为了让你对他们有一个整体上的把握同时能够随时回顾不同环节的作用与效果我把每一个大类的特点、以及咱们讲过的处理函数都整理到了如下的表格中供你参考。
今天的内容很多需要我们多花时间去消化。受2/8理论的支配在机器学习实践中特征工程往往会花费我们80%的时间和精力。由于特征工程制约着模型效果的上限,因此,尽管特征工程的步骤繁多、过程繁琐,但是我们千万不能在这个环节偷懒,一定要认真对待。
这也是为什么我们分为上、下两部分来着重讲解特征工程,从概览到每一个环节,从每一个环节的作用到它包含的具体方法。数据质量构筑了模型效果的天花板,特征工程道阻且长,然而行则将至,让我们一起加油!
每课一练
结合上一讲对于我们介绍过的所有特征处理函数如StringIndexer、ChiSqSelector、MinMaxScaler、Bucketizer、OneHotEncoder和VectorAssembler你能说说他们之间的区别和共同点吗
欢迎你在留言区记录你的收获与思考,也欢迎你向更多同事、朋友分享今天的内容,说不定就能帮他解决特征工程方面的问题。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 模型训练(上):决策树系列算法详解
你好,我是吴磊。
在上一讲我们重点介绍了机器学习中的特征工程以及Spark MLlib框架支持的特征处理函数。基于线性回归模型我们对比了不同特征处理方法下的模型效果。一般来说线性模型的模型容量比较有限它仅适合拟合特征向量与预测标的之间存在线性关系的场景。
但在实际应用中,线性关系少之又少,就拿“房价预测”的项目来说,不同的房屋属性与房价之间,显然不是单纯的线性关系。这也是为什么在房价预测的任务上,线性回归模型的预测误差一直高居不下。因此,为了提升房价预测的准确度,我们有必要从模型选型的角度,着手去考虑采用其他类型的模型算法,尤其是非线性模型。
Spark MLlib框架支持种类丰富的模型算法为了在减轻你学习负担的同时尽量全面地覆盖其中的内容我把模型训练分为了上、中、下三讲。今天这一讲我们专注在决策树系列算法的讲解。
后面两讲我再结合房屋预测和电影推荐场景带你在实践中掌握Spark MLlib模型算法从而让你在不同的场景下得心应手地开展模型选型与模型调优。
课程安排
因为模型训练的部分内容比较丰富为了让你有一个清晰的学习计划咱们还是先来交代一下课程安排。在机器学习领域如果按照“样本是否存在预测标的Label”为标准机器学习问题可以分为监督学习Supervised Learning与非监督学习Unsupervised Learning。Spark MLlib同时支持这两大类机器学习算法如下图所示。
可以看到在Spark MLlib开发框架下按照使用场景不同监督学习又被细分为回归Regression、分类Classification和协同过滤Collaborative Filtering而非监督学习被细分为聚类Clustering与频繁项集Frequency Patterns
不同的分类下Spark MLlib支持的模型算法多样而又庞杂。如果逐一讲解每种算法的原理和用法不但枯燥乏味而且容易遗忘。所以对于每个分类我都会精选一个最具代表性的算法再结合实例进行讲解这样你学完之后印象会更加深刻。
与5个子分类相对应模型训练课程的实例也有5个分别是房价预测、房屋分类、电影推荐1、房屋聚类、电影推荐2。根据数据来源的不同这5个实例又可以分为两类如下图所示。
为了照顾基础薄弱的同学我们需要先搞清楚决策树、GBDTGradient-boosted Decision Trees和RFRandom Forest这些前置知识。学完这节课之后你会发现一个很有趣的现象这些知识点背后的原理跟人类的决策过程惊人的相似但相比人类经验机器又能青出于蓝。
好啦,让我们正式开始今天的学习。
决策树系列算法
马上就是“双十一”了,你可能很想血拼一把,但一摸自己的钱包,理智又占领了高地。试想一下,预算有限的情况下,你会如何挑选一款手机呢?我们往往会结合价位、品牌、评价等一系列因素考量,最后做出决策。
其实这个依据不同决定性因素来构建决策路径的过程,放在机器学习里,就是决策树。接下来,我们用严谨一点的术语再描述一下什么是决策树。
决策树Decision Trees是一种根据样本特征向量而构建的树形结构。决策树由节点Nodes与有向边Vertexes组成其中节点又分为两类一类是内部节点一类是叶子节点。内部节点表示的是样本特征而叶子节点代表分类。
举例来说假设我们想根据“居室数量”和“房屋面积”这两个特征把房屋分为5类。那么我们就可以构建一个决策树来做到这一点如下图所示。
其中椭圆形代表的是内部节点每个内部节点都包含一个特征并同时拥有两条有向边。每条有向边都表示一组特征取值。比方说图中决策树的根节点顶端的内部节点包含的特征是“居室数量”左边的有向边表示的是居室数量小于4的数据样本而右边的有向边代表的是居室数量大于等于4的数据样本。
就这样原始的房屋样本被一分为二按照居室数量被“劈”成了两份。“劈”到左侧的样本继续按照“房屋面积”是否小于6作区分而“劈”到右侧的样本则按照“房屋面积”是否小于10来做进一步的区分。就这样根据不同特征的不同取值范围数据样本一层一层地被加以区分直到圆形节点也即叶子节点为止。
叶子节点表示数据样本的分类图中的5个圆形即代表5个叶子节点。每个叶子节点中都包含若干的数据样本显然掉落到同一个叶子节点的样本同属于一个分类。
不难发现在上面的决策树中结合“居室数量”和“房屋面积”这两个特征的不同取值原始的数据样本被划分成了“不重不漏”的5份子集如下图所示。
基于这5份样本子集我们就有能力去解决分类或是回归问题。假设数据样本中的标签列Label是“房屋质量”数据样本按照房屋质量的取值被分为差、一般、好、很好和极好。
决策树中的5个叶子节点对应的就是Label的5个不同取值。因此凡是掉落在蓝色圆形节点的样本它的房屋质量都是“差”同理凡是掉落在黄色圆形节点的样本对应的房屋质量都是“极好”。如此一来我们便按照“房屋质量”完成了对原始样本的分类过程。
实际上回归过程也是类似的。如果数据样本中的标签不再是离散的“房屋质量”而是连续的“房屋价格”那么我们同样可以利用决策树来完成回归预测。假设我们用100条数据样本来构建上面的决策树并假设每个叶子节点都包含20条数据样本。
那么当有一条新的数据样本需要预测房价的时候我们只需要让它遍历决策树然后看看它掉落到哪一个叶子节点中去。假设它掉落到了Set3这个节点那么要预测这条样本的房价我们就取Set3中那20条样本的房价均值。
好啦,到此为止,我们介绍了什么是决策树,怎么用决策树来预测新的数据样本。不难发现,决策树的推理过程,与人类的决策过程非常相似。
人类也常常“货比三家”,结合生活经验,根据一些关键因素做出决策。说到这里,你可能会好奇:“我做决定的时候,往往是结合生活经验,那么模型算法是依据什么,来构建决策树的呢?它怎么知道,哪些特征是决定性因素,而哪些特征又没什么用呢?”
用一句话来概括数据样本的纯度决定了模型算法选择哪些特征作为内部节点同时也决定着决策树何时收敛。所谓样本纯度简单地说就是标签的多样性Cardinality。对于一个集合中的样本如果样本的标签都一样也即标签的多样性为1那么我们就说这个集合的样本纯度很高。
相反,如果这个集合中的样本标签取值非常多,多样性非常高,那么我们就说这个集合的样本纯度很低。在数学上,我们可以用信息熵来量化样本的纯度(或者说标签多样性),不过作为入门课,咱们暂时不必深究,只要从概念上理解样本的纯度就好。
模型算法在构建决策树的时候,会去遍历每一个特征,并考察每个特征的“提纯”能力。所谓“提纯”,就是把原始样本结合特征进行区分之后,两个样本子集在纯度上有所提升。换句话说,经过候选特征分割后的样本子集,其纯度越高,就代表候选特征的“提纯”能力越高。
正是基于这样的逻辑模型算法依次筛选“提纯”能力最高、次高、第三高的特征逐级地去构建决策树直到收敛为止。对于收敛条件一方面我们可以人为地设置纯度阈值另一方面我们也可以通过设定树的深度Depth、Levels来进行限制。
在理想情况下我们期望决策树每个叶子节点的纯度尽可能地接近于0用信息熵来量化也即每个节点的标签都是一样的。但在实际工作中我们很难做到这一点。不仅如此一般来说一棵决策树的拟合能力是相当有限的它很难把样本的纯度提升得足够高。
这时就要说到GBDTGradient-boosted Decision Trees和RFRandom Forest这两种算法了尽管它们的设计思想各不相同但本质上都是为了进一步提升数据样本的纯度。
Random Forest
Random Forest又叫“随机森林”它的设计思想是“三个臭皮匠、赛过诸葛亮”。既然一棵树的拟合能力有限那么就用多棵树来“凑数儿”毕竟老话说得好人多出韩信。
举例来说,我们想结合多个特征,来对房屋质量进行分类。对于给定的数据样本,随机森林算法会训练多棵决策树,树与树之间是相互独立的,彼此之间不存在任何依赖关系。对于每一棵树,算法会随机选择部分样本与部分特征,来进行决策树的构建,这也是随机森林命名中“随机”一词的由来。
以上图为例随机森林算法构建了3棵决策树第一棵用到了“居室数量”和“房屋面积”这两个特征而第二棵选择了“建筑年龄”、“装修情况”和“房屋类型”三个特征最后一棵树选择的是“是否带泳池”、“房屋面积”、“装修情况”和“厨房数量”四个特征。
每棵树都把遍历的样本分为5个类别每个类别都包含部分样本。当有新的数据样本需要预测房屋质量时我们把数据样本同时“喂给”随机森林的3棵树预测结果取决于3棵树各自的输出结果。
假设样本经过第一棵树的判别之后掉落在了Set3经过第二棵树的“决策”之后掉落在了Set2而经过第三棵树的判定之后归类到了Set3那么样本最终的预测结果就是Set3。也即按照“少数服从多数”的原则随机森林最终的预测结果会取所有决策树结果中的大多数。回归问题也是类似最简单的办法就是取所有决策树判定结果的均值。
GBDT
接下来我们再说说GBDTGradient-boosted Decision Trees。与随机森林类似GBDT也是用多棵决策树来拟合数据样本但是树与树之间是有依赖关系的每一棵树的构建都是基于前一棵树的训练结果。因此与随机森林不同GBDT的设计思想是“站在前人的肩膀上看得更远”如下图所示。
具体来说在GBDT的训练过程中每一棵树的构建都是基于上一棵树输出的“样本残差”。如下图所示预测值与真实值Ground Truth之间的差值即是样本残差。后面决策树的拟合目标不再是原始的房屋价格而是这个样本残差。
以此类推后续的决策树都会基于上一棵树的残差去做拟合从而使得预测值与真实值之间的误差越来越小并最终趋近于0。不难发现只要GBDT训练的决策树足够多预测误差就可以足够小因此GBDT的拟合能力是非常强的。
不过与此同时我们要提防GBDT的过拟合问题在训练集上过分拟合往往会导致模型在测试集上的表现不尽如人意。解决过拟合的思路就是让模型由复杂变得简单要做到这一点我们可以通过限制决策树的数量与深度来降低GBDT模型的复杂度。
好啦到此为止我们学习了决策树以及由决策树衍生的随机森林与GBDT算法。光说不练假把式在下一讲我们就以房价预测和房屋分类为例体会一下在Spark MLlib的框架下具体要如何应用这些算法解决实际问题。
重点回顾
好啦,到此为止,我们今天的内容就全部讲完啦。让我们一起来做个总结。
首先你需要知道Spark MLlib开发框架都支持哪些模型算法我把这些模型算法、以及算法的分类整理到了下面的脑图中供你随时参考。
你需要掌握决策树系列算法的特点与基本原理。其中决策树系列算法既可以用于解决分类问题也可以解决回归问题。相比线性模型树模型拥有更强的非线性拟合能力而且树模型具备良好的可解释性它的工作原理非常符合人类的思考方式。随机森林与GBDT是衍生自决策树的两类集成类算法。
随机森林的设计思想是“三个臭皮匠、赛过诸葛亮”,通过在多棵树上随机选取训练样本与特征,随机森林将多个简单模型集成在一起,用投票的方式共同来决定最终的预测结果。
而GBDT的思想是“站在前人的肩膀上看得更远”它也是基于多棵树的集成模型。与随机森林不同在GBDT中树与树之间是存在依赖关系的。每一棵树的训练都是基于前一棵树拟合的样本残差从而使得预测值不断地逼近真实值。GBDT的特点是拟合能力超强但同时要注意决策树过深、过多而带来的过拟合隐患。
每课一练
结合今天的课程内容你能说说GBDT与Random Forest模型算法各自的优缺点吗
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 模型训练(中):回归、分类和聚类算法详解
你好,我是吴磊。
在上一讲我们学习了决策树系列算法包括决策树、GBDT和随机森林。今天这一讲我们来看看在Spark MLlib框架下如何将这些算法应用到实际的场景中。
你还记得我们给出的Spark MLlib模型算法“全景图”么对于这张“全景图”我们会时常回顾它。一方面它能为我们提供“全局视角”再者有了它我们就能够轻松地把学习过的内容对号入座从而对于学习的进展做到心中有数。
今天这一讲我们会结合房屋预测场景一起学习回归、分类与聚类中的典型算法在Spark MLlib框架下的具体用法。掌握这些用法之后针对同一类机器学习问题回归、分类或是聚类你就可以在其算法集合中灵活、高效地做算法选型。
房屋预测场景
在这个场景中我们有3个实例分别是房价预测、房屋分类和房屋聚类。房价预测我们并不陌生在前面的学习中我们一直在尝试把房价预测得更准。
房屋分类它指的是给定离散标签Label如“OverallQual”房屋质量结合房屋属性特征将所有房屋分类到相应的标签取值如房屋质量的“好、中、差”三类。
而房屋聚类,它指的是,在不存在标签的情况下,根据房屋特征向量,结合“物以类聚”的思想,将相似的房屋聚集到一起,形成聚类。
房价预测
在特征工程的两讲中我们一直尝试使用线性模型来拟合房价但线性模型的拟合能力相当有限。决策树系列模型属于非线性模型在拟合能力上更胜一筹。经过之前的讲解想必你对Spark MLlib框架下模型训练的“套路”已经了然于胸模型训练基本上可以分为3个环节
准备训练样本
定义模型,并拟合训练数据
验证模型效果
除了模型定义第一个与第三个环节实际上是通用的。不论我们采用哪种模型训练样本其实都大同小异度量指标不论是用于回归的RMSE还是用于分类的AUC本身也与模型无关。因此今天这一讲我们把重心放在第二个环节对于代码实现我们在文稿中也只粘贴这一环节的代码其他环节的代码你可以参考特征工程的两讲的内容。
[上一讲]我们学过了决策树系列模型及其衍生算法也就是随机森林与GBDT算法。这两种算法既可以解决分类问题也可以用来解决回归问题。既然GBDT擅长拟合残差那么我们不妨用它来解决房价预测的回归问题而把随机森林留给后面的房屋分类。
要用GBDT来拟合房价我们首先还是先来准备训练样本。
// numericFields代表数值字段indexFields为采用StringIndexer处理后的非数值字段
val assembler = new VectorAssembler()
.setInputCols(numericFields ++ indexFields)
.setOutputCol("features")
// 创建特征向量“features”
engineeringDF = assembler.transform(engineeringDF)
import org.apache.spark.ml.feature.VectorIndexer
// 区分离散特征与连续特征
val vectorIndexer = new VectorIndexer()
.setInputCol("features")
.setOutputCol("indexedFeatures")
// 设定区分阈值
.setMaxCategories(30)
// 完成数据转换
engineeringDF = vectorIndexer.fit(engineeringDF).transform(engineeringDF)
我们之前已经学过了VectorAssembler的用法它用来把多个字段拼接为特征向量。你可能已经发现在VectorAssembler之后我们使用了一个新的特征处理函数对engineeringDF进一步做了转换这个函数叫作VectorIndexer。它是用来干什么的呢
简单地说它用来帮助决策树系列算法如GBDT、随机森林区分离散特征与连续特征。连续特征也即数值型特征数值之间本身是存在大小关系的。而离散特征如街道类型在经过StringIndexer转换为数字之后数字与数字之间会引入原本并不存在的大小关系具体你可以回看[第25讲])。
这个问题要怎么解决呢首先对于经过StringIndexer处理过的离散特征VectorIndexer会进一步对它们编码抹去数字之间的比较关系从而明确告知GBDT等算法该特征为离散特征数字与数字之间相互独立不存在任何关系。
VectorIndexer对象的setMaxCategories方法用于设定阈值该阈值用于区分离散特征与连续特征我们这里设定的阈值为30。这个阈值有什么用呢凡是多样性Cardinality大于30的特征后续的GBDT模型会把它们看作是连续特征而多样性小于30的特征GBDT会把它们当作是离散特征来进行处理。
说到这里,你可能会问:“对于一个特征,区分它是连续的、还是离散的,有这么重要吗?至于这么麻烦吗?”
还记得在决策树基本原理中,特征的“提纯”能力这个概念吗?对于同样一份数据样本,同样一个特征,连续值与离散值的“提纯”能力可能有着天壤之别。还原特征原本的“提纯”能力,将为决策树的合理构建,打下良好的基础。
好啦样本准备好之后接下来我们就要定义并拟合GBDT模型了。
import org.apache.spark.ml.regression.GBTRegressor
// 定义GBDT模型
val gbt = new GBTRegressor()
.setLabelCol("SalePriceInt")
.setFeaturesCol("indexedFeatures")
// 限定每棵树的最大深度
.setMaxDepth(5)
// 限定决策树的最大棵树
.setMaxIter(30)
// 区分训练集、验证集
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 拟合训练数据
val gbtModel = gbt.fit(trainingData)
可以看到我们通过定义GBTRegressor来定义GBDT模型其中setLabelCol、setFeaturesCol都是老生常谈的方法了不再赘述。值得注意的是setMaxDepth和setMaxIter这两个方法用于避免GBDT模型出现过拟合的情况前者限定每棵树的深度而后者直接限制了GBDT模型中决策树的总体数目。后面的训练过程依然是调用模型的fit方法。
到此为止我们介绍了如何通过定义GBDT模型来拟合房价。后面的效果评估环节鼓励你结合[第23讲]的模型验证部分,去自行尝试,加油!
房屋分类
接下来我们再来说说房屋分类。我们知道在“House Prices - Advanced Regression Techniques”竞赛项目中数据集总共有79个字段。在之前我们一直把售价SalePrice当作是预测标的也就是Label而用其他字段构建特征向量。
现在我们来换个视角把房屋质量OverallQual看作是Label让售价SalePrice作为普通字段去参与构建特征向量。在房价预测的数据集中房屋质量是离散特征它的取值总共有10个如下图所示。
如此一来我们就把先前的回归问题预测连续值转换成了分类问题预测离散值。不过不管是什么机器学习问题模型训练都离不开那3个环节
准备训练样本
定义模型,并拟合训练数据
验证模型效果
在训练样本的准备上除了把预测标的从SalePrice替换为OverallQual我们完全可以复用刚刚使用GBDT来预测房价的代码实现。
// Label字段"OverallQual"
val labelField: String = "OverallQual"
import org.apache.spark.sql.types.IntegerType
engineeringDF = engineeringDF
.withColumn("indexedOverallQual", col(labelField).cast(IntegerType))
.drop(labelField)
接下来我们就可以定义随机森林模型、并拟合训练数据。实际上除了类名不同RandomForestClassifier在用法上与GBDT的GBTRegressor几乎一模一样如下面的代码片段所示。
import org.apache.spark.ml.regression.RandomForestClassifier
// 定义随机森林模型
val rf= new RandomForestClassifier ()
// Label不再是房价而是房屋质量
.setLabelCol("indexedOverallQual")
.setFeaturesCol("indexedFeatures")
// 限定每棵树的最大深度
.setMaxDepth(5)
// 限定决策树的最大棵树
.setMaxIter(30)
// 区分训练集、验证集
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 拟合训练数据
val rfModel = rf.fit(trainingData)
模型训练好之后,在第三个环节,我们来初步验证模型效果。
需要注意的是衡量模型效果时回归与分类问题各自有一套不同的度量指标。毕竟回归问题预测的是连续值我们往往用不同形式的误差如RMSE、MAE、MAPE等等来评价回归模型的好坏。而分类问题预测的是离散值因此我们通常采用那些能够评估分类“纯度”的指标比如说准确度、精准率、召回率等等。
这里我们以Accuracy准确度为例来评估随机森林模型的拟合效果代码如下所示。
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
// 在训练集上做推理
val trainPredictions = rfModel.transform(trainingData)
// 定义分类问题的评估对象
val evaluator = new MulticlassClassificationEvaluator()
.setLabelCol("indexedOverallQual")
.setPredictionCol("prediction")
.setMetricName("accuracy")
// 在训练集的推理结果上计算Accuracy度量值
val accuracy = evaluator.evaluate(trainPredictions)
好啦到此为止我们以房价预测和房屋分类为例分别介绍了如何在Spark MLlib框架下去应对回归问题与分类问题。分类与回归是监督学习中最典型的两类模型算法是我们必须要熟悉并掌握的。接下来让我们以房屋聚类为例说一说非监督学习。
房屋聚类
与监督学习相对非监督学习泛指那些数据样本中没有Label的机器学习问题。
以房屋数据为例整个数据集包含79个字段。如果我们把“SalePrice”和“OverallQual”这两个字段抹掉那么原始数据集就变成了不带Label的数据样本。你可能会好奇“对于这些没有Label的样本我们能拿他们做些什么呢
其实能做的事情还真不少基于房屋数据我们可以结合“物以类聚”的思想使用K-means算法把他们进行分门别类的处理。再者在下一讲电影推荐的例子中我们还可以基于频繁项集算法挖掘出不同电影之间共现的频次与关联规则从而实现推荐。
今天我们先来讲K-mean结合数据样本的特征向量根据向量之间的相对距离K-means算法可以把所有样本划分为K个类别这也是算法命名中“K”的由来。举例来说图中的每个点都代表一个向量给定不同的K值K-means划分的结果会随着K的变化而变化。
在Spark MLlib的开发框架下我们可以轻而易举地对任意向量做聚类。
首先在模型训练的第一个环节我们先把训练样本准备好。注意这一次我们去掉了“SalePrice”和“OverallQual”这两个字段。
import org.apache.spark.ml.feature.VectorAssembler
val assembler = new VectorAssembler()
// numericFields包含连续特征oheFields为离散特征的One hot编码
.setInputCols(numericFields ++ oheFields)
.setOutputCol("features")
接下来在第二个环节我们来定义K-means模型并使用刚刚准备好的样本去做模型训练。可以看到模型定义非常简单只需实例化KMeans对象并通过setK指定K值即可。
import org.apache.spark.ml.clustering.KMeans
val kmeans = new KMeans().setK(20)
val Array(trainingSet, testSet) = engineeringDF
.select("features")
.randomSplit(Array(0.7, 0.3))
val model = kmeans.fit(trainingSet)
这里我们准备把不同的房屋划分为20个不同的类别。完成训练之后我们同样需要对模型效果进行评估。由于数据样本没有Label因此先前回归与分类的评估指标不适合像K-means这样的非监督学习算法。
K-means的设计思想是“物以类聚”既然如此那么同一个类别中的向量应该足够地接近而不同类别中向量之间的距离应该越远越好。因此我们可以用距离类的度量指标如欧氏距离来量化K-means的模型效果。
import org.apache.spark.ml.evaluation.ClusteringEvaluator
val predictions = model.transform(trainingSet)
// 定义聚类评估器
val evaluator = new ClusteringEvaluator()
// 计算所有向量到分类中心点的欧氏距离
val euclidean = evaluator.evaluate(predictions)
好啦到此为止我们使用非监督学习算法K-means根据房屋向量对房屋类型进行了划分。不过你要注意使用这种方法划分出的类型是没有真实含义的比如它不能代表房屋质量也不能代表房屋评级。既然如此我们用K-means忙活了半天图啥呢
尽管K-means的结果没有真实含义但是它以量化的形式刻画了房屋之间的相似性与差异性。你可以这样来理解我们用K-means为房屋生成了新的特征相比现有的房屋属性这个生成的新特征Generated Features往往与预测标的如房价、房屋类型有着更强的关联性所以让这个新特性参与到监督学习的训练就有希望优化/提升监督学习的模型效果。
好啦到此为止结合房价预测、房屋分类和房屋聚类三个实例我们成功打卡了回归、分类和聚类这三类模型算法。恭喜你离Spark MLlib模型算法通关咱们还有一步之遥。在下一讲我们会结合电影推荐的场景继续学习两个有趣的模型算法协同过滤与频繁项集。
重点回顾
今天这一讲你首先需要掌握K-means算法的基本原理。聚类的设计思想是“物以类聚、人以群分”给定任意向量集合K-means都可以把它划分为K个子集合从而完成聚类。
K-means的计算主要依赖向量之间的相对距离它的计算结果一方面可以直接用于划分“人群”、“种群”另一方面可以拿来当做生成特征去参与到监督学习的训练中去。
此外你需要掌握GBTRegressor和RandomForestClassifier的一般用法。其中setLabelCol与setFeaturesCol分别用于指定模型的预测标的与特征向量。而setMaxDepth与setMaxIter分别用于设置模型的超参数也即最大树深与最大迭代次数决策树的数量从而避免模型出现过拟合的情况。
每课一练
对于房价预测与房屋分类这两个场景,你觉得在它们之间,有代码(尤其是特征工程部分的代码)复用的必要和可能性吗?
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View File

@ -0,0 +1,260 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 模型训练(下):协同过滤与频繁项集算法详解
你好,我是吴磊。
如果你平时爱刷抖音或者热衷看电影不知道有没有过这样的体验这类影视App你用得越久它就好像会读心术一样总能给你推荐对胃口的内容。其实这种迎合用户喜好的推荐离不开机器学习中的推荐算法。
今天是咱们模型训练的最后一讲在今天这一讲我们就结合两个有趣的电影推荐场景为你讲解Spark MLlib支持的协同过滤与频繁项集算法。与上一讲一样咱们还是先来贴出下面这张“全景图”方便你对学过和即将要学的知识做到心中有数。
电影推荐场景
今天这一讲咱们结合Kaggle竞赛中的MovieLens数据集使用不同算法来构建简易的电影推荐引擎。尽管MovieLens数据集包含了多个文件但课程中主要用到的是ratings.csv这个文件。文件中的每条数据条目记录的都是用户对于电影的打分如下表所示。
其中第一列userId为用户IDmovieId表示电影ID而rating就是用户对于电影的评分。像这样同时存有用户与物品电影信息的二维表我们把它们统称为“交互矩阵”或是“共现矩阵”。你可能会疑惑通过这么一份简单的二维表我们能干些什么呢
可别小瞧这份数据与合适的模型算法搭配在一起我就能根据它们构建初具模样的推荐引擎。在Spark MLlib框架下至少有两种模型算法可以做到这一点一个是协同过滤Collaborative Filtering另一个是频繁项集Frequency Patterns。其中前者天生就是用来做推荐用的而后者是一种常规的非监督学习算法你可以结合数据特点把这个算法灵活运用于推荐场景。
协同过滤
我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。
这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。
交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单?
那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。
在数学上给定维度为MN的交互矩阵C我们可以把它分解为两个矩阵U与I的乘积。其中我们可以把U称作“用户矩阵”它的维度为MK而I可以看作是“物品矩阵”它的维度是KN
在用户矩阵与物品矩阵中K是超参数它是由开发者人为设定的。不难发现对于用户矩阵U中的每一行 都可以看作是用户的Embedding也即刻画用户的特征向量。同理物品矩阵中的每一列也都可以看作是物品的Embedding也即刻画物品的特征向量。
正所谓万物皆可Embedding。对于任何事物一旦它们被映射到同一个向量空间我们就可以使用欧氏距离或是余弦夹角等方法来计算他们向量之间的相似度从而实现上述各种相似性用户与用户、物品与物品、用户与物品的量化。
基于相似度计算我们就可以翻着花样地去实现各式各样的推荐。比方说对于用户A来说首先搜索与他/她最相似的前5个用户然后把这些用户喜欢过的物品电影推荐给用户A这样的推荐方式又叫基于用户相似度的推荐。
再比如对于用户A喜欢过的物品我们搜索与这些物品最相似的前5个物品然后把这些搜索到的物品再推荐给用户A这叫做基于物品相似度的推荐。
甚至在一些情况下我们还可以直接计算用户A与所有物品之间的相似度然后把排名靠前的5个物品直接推荐给用户A。
基于上述逻辑我们还可以反其道而行之从物品的视角出发给物品电影推荐用户。不难发现一旦完成Embedding的转换过程我们就可以根据相似度计算来灵活地设计推荐系统。
那么接下来的问题是在Spark MLlib的框架下我们具体要怎么做才能从原始的互动矩阵获得分解之后的用户矩阵、物品矩阵进而获取到用户与物品的Embedding并最终设计出简易的推荐引擎呢
按照惯例,我们还是先上代码,用代码来演示这个过程。
import org.apache.spark.sql.DataFrame
// rootPath表示数据集根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/ratings.csv"
var data: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 类型转换
import org.apache.spark.sql.types.IntegerType
import org.apache.spark.sql.types.FloatType
// 把ID类字段转换为整型把Rating转换为Float类型
data = data.withColumn(s"userIdInt",col("userId").cast(IntegerType)).drop("userId")
data = data.withColumn(s"movieIdInt",col("movieId").cast(IntegerType)).drop("movieId")
data = data.withColumn(s"ratingFloat",col("rating").cast(IntegerType)).drop("rating")
// 切割训练与验证数据集
val Array(trainingData, testData) = data.randomSplit(Array(0.8, 0.2))
第一步还是准备训练样本我们从ratings.csv创建DataFrame然后对相应字段做类型转换以备后面使用。第二步我们定义并拟合模型完成协同过滤中的矩阵分解。
import org.apache.spark.ml.recommendation.ALS
// 基于ALSAlternative Least Squares交替最小二乘构建模型完成矩阵分解
val als = new ALS()
.setUserCol("userIdInt")
.setItemCol("movieIdInt")
.setRatingCol("ratingFloat")
.setMaxIter(20)
val alsModel = als.fit(trainingData)
值得一提的是在Spark MLlib的框架下对于协同过滤的实现Spark并没有采用解析解的方式数学上严格的矩阵分解而是用了一种近似的方式来去近似矩阵分解。这种方式就是ALSAlternative Least Squares交替最小二乘
具体来说给定交互矩阵C对于用户矩阵U与物品矩阵ISpark先给U设定一个初始值然后假设U是不变的在这种情况下Spark把物品矩阵I的优化转化为回归问题不停地去拟合I直到收敛。然后固定住物品矩阵I再用回归的思路去优化用户矩阵U直至收敛。如此反复交替数次U和I都逐渐收敛到最优解Spark即宣告训练过程结束。
因为Spark把矩阵分解转化成了回归问题所以我们可以用回归相关的度量指标来衡量ALS模型的训练效果如下所示。
import org.apache.spark.ml.evaluation.RegressionEvaluator
val evaluator = new RegressionEvaluator()
// 设定度量指标为RMSE
.setMetricName("rmse")
.setLabelCol("ratingFloat")
.setPredictionCol("prediction")
val predictions = alsModel.transform(trainingData)
// 计算RMSE
val rmse = evaluator.evaluate(predictions)
验证过模型效果之后接下来我们就可以放心地从模型当中去获取训练好的用户矩阵U和物品矩阵I。这两个矩阵中保存的正是用户Embedding与物品Embedding。
alsModel.userFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
alsModel.userFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[0.53652495, -1.0...|
+---+--------------------+
*/
alsModel.itemFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
alsModel.itemFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[1.1281404, -0.59...|
+---+--------------------+
*/
就像我们之前说的有了用户与物品的Embedding我们就可以灵活地设计推荐引擎。如果我们想偷懒的话还可以利用Spark MLlib提供的API来做推荐。具体来说我们可以通过调用ALS Model的相关方法来实现向用户推荐物品或是向物品推荐用户如下所示。
// 为所有用户推荐10部电影
val userRecs = alsModel.recommendForAllUsers(10)
// 为每部电影推荐10个用户
val movieRecs = alsModel.recommendForAllItems(10)
// 为指定用户推荐10部电影
val users = data.select(als.getUserCol).distinct().limit(3)
val userSubsetRecs = alsModel.recommendForUserSubset(users, 10)
// 为指定电影推荐10个用户
val movies = data.select(als.getItemCol).distinct().limit(3)
val movieSubSetRecs = alsModel.recommendForItemSubset(movies, 10)
好啦到此为止我们介绍了协同过滤的核心思想与工作原理并使用Spark MLlib提供的ALS算法实现了一个简单的电影推荐引擎。接下来我们再来想一想还有没有其他的思路来打造一个不一样的推荐引擎。
频繁项集
频繁项集Frequency Patterns是一种经典的数据挖掘算法我们可以把它归类到非监督学习的范畴。频繁项集可以挖掘数据集中那些经常“成群结队”出现的数据项并尝试在它们之间建立关联规则Association Rules从而为决策提供支持。
举例来说,基于对上百万条交易记录的统计分析,蔬果超市发现(“葱”,“姜”,“蒜”)这三种食材经常一起出现。换句话说,购买了“葱”、“姜”的人,往往也会再买上几头蒜,或是买了大葱的人,结账前还会再把姜、蒜也捎上。
在这个购物篮的例子中“葱”“姜”“蒜”就是频繁项Frequency Itemset也即经常一起共现的数据项集合。而像“葱”、“姜”->“蒜”)和(“葱”->“姜”、“蒜”)这样的关联关系,就叫做关联规则。
不难发现,基于频繁项与关联规则,我们能够提供简单的推荐能力。以刚刚的(“葱”,“姜”,“蒜”)为例,对于那些手中提着大葱、准备结账的人,精明的导购员完全可以向他/她推荐超市新上的河北白皮蒜或是山东大生姜。
回到电影推荐的场景,我们同样可以基于历史,挖掘出频繁项和关联规则。比方说,电影(“八佰”、“金刚川”、“长津湖”)是频繁项,而(“八佰”、“金刚川”->“长津湖”)之间存在着关联关系。那么,对于看过“八佰”和“金刚川”的人,我们更倾向于判断他/她大概率也会喜欢“长津湖”,进而把这部电影推荐给他/她。
那么基于MovieLens数据集在Spark MLlib的开发框架下我们该如何挖掘其中的频繁项与关联规则呢
首先第一步是数据准备。在蔬果超市的例子中超市需要以交易为单位收集顾客曾经一起购买过的各种蔬果。为了在MovieLens数据集上计算频繁项集我们也需要以用户为粒度收集同一个用户曾经看过的所有电影集合如下图所示。
要完成这样的转换,我们只需要一行代码即可搞定。
// data是从ratings.csv创建的DataFrame
val movies: DataFrame = data
// 按照用户分组
.groupBy("userId")
// 收集该用户看过的所有电影把新的集合列命名为movieSeq
.agg(collect_list("movieId").alias("movieSeq"))
// 只保留movieSeq这一列去掉其他列
.select("movieSeq")
// movies: org.apache.spark.sql.DataFrame = [movieSeq: array<string>]
movies.show(1)
/** 结果打印
+--------------------+
| movieSeq|
+--------------------+
|[151, 172, 236, 2...|
+--------------------+
*/
数据准备好之后接下来我们就可以借助Spark MLlib框架来完成频繁项集的计算。
import org.apache.spark.ml.fpm.FPGrowth
val fpGrowth = new FPGrowth()
// 指定输入列
.setItemsCol("movieSeq")
// 超参数,频繁项最小支持系数
.setMinSupport(0.1)
// 超参数,关联规则最小信心系数
.setMinConfidence(0.1)
val model = fpGrowth.fit(movies)
可以看到定义并拟合频繁项集模型还是比较简单的用法上与其他模型算法大同小异。不过这里有两个超参数需要特别关注一个是由setMinSupport设置的最小支持系数另一个是由setMinConfidence指定的最小信心系数。
最小支持系数它用来设定频繁项的“选拔阈值”这里我们把它设置为0.1。这是什么意思呢?
举例来说在MovieLens数据集中总共有7120个用户相应地movies这个DataFrame中就有7120条电影集合数据。对于“八佰”、“金刚川”、“长津湖”这个组合来说当且仅当它出现的次数大于7127120 * 0.1),这个组合才会被算法判定为频繁项。换句话说,最小支持系数越高,算法挖掘出的频繁项越少、越可靠,反之越多。
相应地最小信心系数是用来约束关联规则的例子中的取值也是0.1。我们再来举例说明假设在7120条电影集合数据中“八佰”、“金刚川”这对组合一起出现过1000次那么要想“八佰”、“金刚川”->“长津湖”这条关联规则成立“八佰”、“金刚川”、“长津湖”这个组合必须至少出现过100次1000 * 0.1)。同理,最小信心系数越高,算法挖掘出的关联规则越少、越可靠,反之越多。
模型训练好之后,我们就可以从中获取经常出现的频繁项与关联规则,如下所示。
model.freqItemsets.show(1)
/** 结果打印
+--------------------+----+
| items|freq|
+--------------------+----+
|[318, 593, 356, 296]|1465|
+--------------------+----+
*/
model.associationRules.show(1)
/** 结果打印
+--------------------+----------+------------------+
| antecedent|consequent| confidence|
+--------------------+----------+------------------+
|[592, 780, 480, 593]| [296]|0.8910463861920173|
+--------------------+----------+------------------+
*/
基于关联规则我们就可以提供初步的推荐功能。比方说对于看过592、780、480、593这四部电影的用户我们可以把ID为296的电影推荐给他/她。
重点回顾
好啦,到此为止,模型训练的上、中、下三讲,我们就全部讲完啦!这三讲的内容较多,涉及的算法也很多,为了让你对他们有一个整体的把握,我把这些算法的分类、原理、特点与适用场景,都整理到了如下的表格中,供你随时回顾。
不难发现,机器学习的场景众多,不同的场景下,又有多种不同的算法供我们选择。掌握这些算法的原理与特性,有利于我们高效地进行模型选型与模型训练,从而去解决不同场景下的特定问题。
对于算法的调优与应用,还需要你结合日常的实践去进一步验证、巩固,也欢迎你在留言区分享你的心得与体会,让我们一起加油!
每课一练
对于本讲介绍的两种推荐思路(协同过滤与频繁项集),你能说说他们各自的优劣势吗?
你有什么学习收获或者疑问,都可以跟我交流,咱们留言区见。

View File

@ -0,0 +1,330 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 Spark MLlib Pipeline高效开发机器学习应用
你好,我是吴磊。
前面我们一起学习了如何在Spark MLlib框架下做特征工程与模型训练。不论是特征工程还是模型训练针对同一个机器学习问题我们往往需要尝试不同的特征处理方法或是模型算法。
结合之前的大量实例,细心的你想必早已发现,针对同一问题,不同的算法选型在开发的过程中,存在着大量的重复性代码。
以GBDT和随机森林为例它们处理数据的过程是相似的原始数据都是经过StringIndexer、VectorAssembler和VectorIndexer这三个环节转化为训练样本只不过GBDT最后用GBTRegressor来做回归而随机森林用RandomForestClassifier来做分类。
不仅如此,在之前验证模型效果的时候我们也没有闭环,仅仅检查了训练集上的拟合效果,并没有在测试集上进行推理并验证。如果我们尝试去加载新的测试数据集,那么所有的特征处理过程,都需要在测试集上重演一遍。无疑,这同样会引入大量冗余的重复代码。
那么有没有什么办法能够避免上述的重复开发让Spark MLlib框架下的机器学习开发更加高效呢答案是肯定的今天这一讲我们就来说说Spark MLlib Pipeline看看它如何帮助开发者大幅提升机器学习应用的开发效率。
Spark MLlib Pipeline
什么是Spark MLlib Pipeline呢简单地说Pipeline是一套基于DataFrame的高阶开发API它让开发者以一种高效的方式来打造端到端的机器学习流水线。这么说可能比较抽象我们不妨先来看看Pipeline都有哪些核心组件它们又提供了哪些功能。
Pipeline的核心组件有两类一类是Transformer我们不妨把它称作“转换器”另一类是Estimator我把它叫作“模型生成器”。我们之前接触的各类特征处理函数实际上都属于转换器比如StringIndexer、MinMaxScaler、Bucketizer、VectorAssembler等等。而前面3讲提到的模型算法全部都是Estimator。
Transformer
我们先来说说Transformer数据转换器。在形式上Transformer的输入是DataFrame输出也是DataFrame。结合特定的数据处理逻辑Transformer基于原有的DataFrame数据列去创建新的数据列而新的数据列中往往包含着不同形式的特征。
以StringIndexer为例它的转换逻辑很简单就是把字符串转换为数值。在创建StringIndexer实例的时候我们需要使用setInputCol(s)和setOutputCol(s)方法,来指定原始数据列和期待输出的数据列,而输出数据列中的内容就是我们需要的特征,如下图所示。
结合图示可以看到Transformer消费原有DataFrame的数据列然后把生成的数据列再追加到该DataFrame就会生成新的DataFrame。换句话说Transformer并不是“就地”Inline修改原有的DataFrame而是基于它去创建新的DataFrame。
实际上每个Transformer都实现了setInputCol(s)和setOutputCol(s)这两个接口方法。除此之外Transformer还提供了transform接口用于封装具体的转换逻辑。正是基于这些核心接口Pipeline才能把各式各样的Transformer拼接在一起打造出了特征工程流水线。
一般来说在一个机器学习应用中我们往往需要多个Transformer来对数据做各式各样的转换才能生成所需的训练样本。在逻辑上多个基于同一份原始数据生成的、不同“版本”数据的DataFrame它们会同时存在于系统中。
不过受益于Spark的惰性求值Lazy Evaluation设计应用在运行时并不会出现多份冗余数据重复占用内存的情况。
不过为了开发上的遍历我们还是会使用var而不是用val来命名原始的DataFrame。原因很简单如果用val的话我们需要反复使用新的变量名来命名新生成的DataFrame。关于这部分开发小细节你可以通过回顾[上一讲]的代码来体会。
Estimator
接下来我们来说说Estimator。相比TransformerEstimator要简单得多它实际上就是各类模型算法如GBDT、随机森林、线性回归等等。Estimator的核心接口只有一个那就是fit中文可以翻译成“拟合”。
Estimator的作用就是定义模型算法然后通过拟合DataFrame所囊括的训练样本来生产模型Models。这也是为什么我把Estimator称作是“模型生成器”。
不过有意思的是虽然模型算法是Estimator但是Estimator生产的模型却是不折不扣的Transformer。
要搞清楚为什么模型是Transformer我们得先弄明白模型到底是什么。所谓机器学习模型它本质上就是一个参数Parameters又称权重Weights矩阵外加一个模型结构。模型结构与模型算法有关比如决策树结构、GBDT结构、神经网络结构等等。
模型的核心用途就是做推断Inference或者说预测。给定数据样本模型可以推断房价、推断房屋类型等等。在Spark MLlib框架下数据样本往往是由DataFrame封装的而模型推断的结果还是保存在新的DataFrame中结果的默认列名是“predictions”。
其实基于训练好的推理逻辑通过增加“predictions”列把一个DataFrame转化成一个新的DataFrame这不就是Transformer在做的事情吗而这也是为什么在模型算法上我们调用的是fit方法而在做模型推断时我们在模型上调用的是transform方法。
构建Pipeline
好啦了解了Transformer和Estimator之后我们就可以基于它们去构建Pipeline来打造端到端的机器学习流水线。实际上一旦Transformer、Estimator准备就绪定义Pipeline只需一行代码就可以轻松拿下如下所示。
import org.apache.spark.ml.Pipeline
// 像之前一样,定义各种特征处理对象与模型算法
val stringIndexer = _
val vectorAssembler = _
val vectorIndexer = _
val gbtRegressor = _
// 将所有的Transformer、Estimator依序放入数组
val stages = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
// 定义Spark MLlib Pipeline
val newPipeline = new Pipeline()
.setStages(stages)
可以看到要定义Pipeline只需创建Pipeline实例然后把之前定义好的Transformer、Estimator纷纷作为参数传入setStages方法即可。需要注意的是一个Pipeline可以包含多个Transformer和Estimator不过Pipeline的最后一个环节必须是Estimator切记。
到此为止Pipeline的作用、定义以及核心组件我们就讲完了。不过你可能会说“概念是讲完了不过我还是不知道Pipeline具体怎么用以及它到底有什么优势”别着急光说不练假把式接下来我们就结合GBDT与随机森林的例子来说说Pipeline的具体用法以及怎么用它帮你大幅度提升开发效率。
首先我们来看看在一个机器学习应用中Pipeline如何帮助我们提高效率。在上一讲我们用GBDT来拟合房价并给出了代码示例。
现在咱们把代码稍微调整一下用Spark MLlib Pipeline来实现模型训练。第一步我们还是先从文件创建DataFrame然后把数值型字段与非数值型字段区分开如下所示。
import org.apache.spark.sql.DataFrame
// rootPath为房价预测数据集根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
// 读取文件创建DataFrame
var engineeringDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 所有数值型字段
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// Label字段
val labelFields: Array[String] = Array("SalePrice")
import org.apache.spark.sql.types.IntegerType
for (field <- (numericFields ++ labelFields)) {
engineeringDF = engineeringDF
.withColumn(s"${field}Int",col(field).cast(IntegerType))
.drop(field)
}
数据准备好之后接下来我们就可以开始着手为Pipeline的构建打造零件依次定义转换器Transformer和模型生成器Estimator。在上一讲我们用StringIndexer把非数值字段转换为数值字段这一讲咱们也依法炮制。
import org.apache.spark.ml.feature.StringIndexer
// 所有非数值型字段
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
// StringIndexer期望的输出列名
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 定义StringIndexer实例
val stringIndexer = new StringIndexer()
// 批量指定输入列名
.setInputCols(categoricalFields)
// 批量指定输出列名,输出列名与输入列名,必须要一一对应
.setOutputCols(indexFields)
.setHandleInvalid("keep")
在上一讲定义完StringIndexer实例之后我们立即拿它去对engineeringDF做转换。不过在构建Pipeline的时候我们不需要这么做只需要把这个“零件”定义好即可。接下来我们来打造下一个零件VectorAssembler。
import org.apache.spark.ml.feature.VectorAssembler
// 转换为整型的数值型字段
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
val vectorAssembler = new VectorAssembler()
/** 输入列为:数值型字段 + 非数值型字段
注意非数值型字段的列名要用indexFields
而不能用原始的categoricalFields不妨想一想为什么
*/
.setInputCols(numericFeatures ++ indexFields)
.setOutputCol("features")
.setHandleInvalid("keep")
与上一讲相比VectorAssembler的定义并没有什么两样。
下面我们继续来打造第三个零件VectorIndexer它用于帮助模型算法区分连续特征与离散特征。
import org.apache.spark.ml.feature.VectorIndexer
val vectorIndexer = new VectorIndexer()
// 指定输入列
.setInputCol("features")
// 指定输出列
.setOutputCol("indexedFeatures")
// 指定连续、离散判定阈值
.setMaxCategories(30)
.setHandleInvalid("keep")
到此为止Transformer就全部定义完了原始数据经过StringIndexer、VectorAssembler和VectorIndexer的转换之后会生成新的DataFrame。在这个最新的DataFrame中会有多个由不同Transformer生成的数据列其中“indexedFeatures”列包含的数据内容即是特征向量。
结合DataFrame一路携带过来的“SalePriceInt”列特征向量与预测标的终于结合在一起了就是我们常说的训练样本。有了训练样本接下来我们就可以着手定义Estimator。
import org.apache.spark.ml.regression.GBTRegressor
val gbtRegressor = new GBTRegressor()
// 指定预测标的
.setLabelCol("SalePriceInt")
// 指定特征向量
.setFeaturesCol("indexedFeatures")
// 指定决策树的数量
.setMaxIter(30)
// 指定决策树的最大深度
.setMaxDepth(5)
好啦到这里Pipeline所需的零件全部打造完毕零件就位只欠组装。我们需要通过Spark MLlib提供的“流水线工艺”把所有零件组装成Pipeline。
import org.apache.spark.ml.Pipeline
val components = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
val pipeline = new Pipeline()
.setStages(components)
怎么样是不是很简单接下来的问题是有了Pipeline我们都能用它做些什么呢
// Pipeline保存地址的根目录
val savePath: String = _
// 将Pipeline物化到磁盘以备后用复用
pipeline.write
.overwrite()
.save(s"${savePath}/unfit-gbdt-pipeline")
// 划分出训练集和验证集
val Array(trainingData, validationData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 调用fit方法触发Pipeline计算并最终拟合出模型
val pipelineModel = pipeline.fit(trainingData)
首先我们可以把Pipeline保存下来以备后用至于怎么复用我们待会再说。再者把之前准备好的训练样本传递给Pipeline的fit方法即可触发整条Pipeline从头至尾的计算逻辑从各式各样的数据转换到最终的模型训练一步到位。-
Pipeline fit方法的输出结果即是训练好的机器学习模型。我们最开始说过模型也是Transformer它可以用来推断预测结果。
看到这里你可能会说“和之前的代码实现相比Pipeline也没有什么特别之处无非是用Pipeline API把之前的环节拼接起来而已”。其实不然基于构建好的Pipeline我们可以在不同范围对其进行复用。对于机器学习应用来说我们既可以在作业内部实现复用也可以在作业之间实现复用从而大幅度提升开发效率。
作业内的代码复用
在之前的模型训练过程中我们仅仅在训练集与验证集上评估了模型效果。实际上在工业级应用中我们最关心的是模型在测试集上的泛化能力。就拿Kaggle竞赛来说对于每一个机器学习项目Kaggle都会同时提供train.csv和test.csv两个文件。
其中train.csv是带标签的用于训练模型而test.csv是不带标签的。我们需要对test.csv中的数据做推断然后把预测结果提交到Kaggle线上平台平台会结合房屋的实际价格来评判我们的模型到那时我们才能知道模型对于房价的预测到底有多准或是有多不准
要完成对test.csv的推断我们需要把原始数据转换为特征向量也就是把“粗粮”转化为“细粮”然后才能把它“喂给”模型。
在之前的代码实现中要做到这一点我们必须把之前加持到train.csv的所有转换逻辑都重写一遍比如StringIndexer、VectorAssembler和VectorIndexer。毫无疑问这样的开发方式是极其低效的更糟的是手工重写很容易会造成测试样本与训练样本不一致而这样的不一致是机器学习应用中的大忌。
不过有了Pipeline我们就可以省去这些麻烦。首先我们把test.csv加载进来并创建DataFrame然后把数值字段从String转为Int。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
val filePath: String = s"${rootPath}/test.csv"
// 加载test.csv并创建DataFrame
var testData: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 所有数值型字段
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// 所有非数值型字段
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
import org.apache.spark.sql.types.IntegerType
// 注意test.csv没有SalePrice字段也即没有Label
for (field <- (numericFields)) {
testData = testData
.withColumn(s"${field}Int",col(field).cast(IntegerType))
.drop(field)
}
接下来我们只需要调用Pipeline Model的transform方法就可以对测试集做推理。还记得吗模型是Transformer而transform是Transformer用于数据转换的统一接口。
val predictions = pipelineModel.transform(testData)
有了Pipeline我们就可以省去StringIndexer、VectorAssembler这些特征处理函数的重复定义在提升开发效率的同时消除样本不一致的隐患。除了在同一个作业内部复用Pipeline之外我们还可以在不同的作业之间对其进行复用从而进一步提升开发效率。
作业间的代码复用
对于同一个机器学习问题我们往往会尝试不同的模型算法以期获得更好的模型效果。例如对于房价预测我们既可以用GBDT也可以用随机森林。不过尽管模型算法不同但是它们的训练样本往往是类似的甚至是完全一样的。如果每尝试一种模型算法就需要从头处理一遍数据这未免过于低效也容易出错。
有了Pipeline我们就可以把算法选型这件事变得异常简单。还是拿房价预测来举例之前我们尝试使用GBTRegressor来训练模型这一次咱们来试试RandomForestRegressor也即使用随机森林来解决回归问题。按照惯例我们还是结合代码来进行讲解。
import org.apache.spark.ml.Pipeline
val savePath: String = _
// 加载之前保存到磁盘的Pipeline
val unfitPipeline = Pipeline.load(s"${savePath}/unfit-gbdt-pipeline")
// 获取Pipeline中的每一个StageTransformer或Estimator
val formerStages = unfitPipeline.getStages
// 去掉Pipeline中最后一个组件也即EstimatorGBTRegressor
val formerStagesWithoutModel = formerStages.dropRight(1)
import org.apache.spark.ml.regression.RandomForestRegressor
// 定义新的EstimatorRandomForestRegressor
val rf = new RandomForestRegressor()
.setLabelCol("SalePriceInt")
.setFeaturesCol("indexedFeatures")
.setNumTrees(30)
.setMaxDepth(5)
// 将老的Stages与新的Estimator拼接在一起
val stages = formerStagesWithoutModel ++ Array(rf)
// 重新定义新的Pipeline
val newPipeline = new Pipeline()
.setStages(stages)
首先我们把之前保存下来的Pipeline重新加载进来。然后用新的RandomForestRegressor替换原来的GBTRegressor。最后再把原有的Stages和新的Estimator拼接在一起去创建新的Pipeline即可。接下来只要调用fit方法就可以触发新Pipeline的运转并最终拟合出新的随机森林模型。
// 像之前一样从train.csv创建DataFrame准备数据
var engineeringDF = _
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 调用fit方法触发Pipeline运转拟合新模型
val pipelineModel = newPipeline.fit(trainingData)
可以看到短短的几行代码就可以让我们轻松地完成模型选型。到此Pipeline在开发效率与容错上的优势可谓一览无余。
重点回顾
今天的内容就讲完啦今天这一讲我们一起学习了Spark MLlib Pipeline。你需要理解Pipeline的优势所在并掌握它的核心组件与具体用法。Pipeline的核心组件是Transformer与Estimator。
其中Transformer完成从DataFrame到DataFrame的转换基于固有的转换逻辑生成新的数据列。Estimator主要是模型算法它基于DataFrame中封装的训练样本去生成机器学习模型。将若干Transformer与Estimator拼接在一起通过调用Pipeline的setStages方法即可完成Pipeline的创建。
Pipeline的核心优势在于提升机器学习应用的开发效率并同时消除测试样本与训练样本之间不一致这一致命隐患。Pipeline可用于作业内的代码复用或是作业间的代码复用。
在同一作业内Pipeline能够轻松地在测试集之上完成数据推断。而在作业之间开发者可以加载之前保存好的Pipeline然后用“新零件”替换“旧零件”的方式在复用大部分处理逻辑的同时去打造新的Pipeline从而实现高效的模型选型过程。
在今后的机器学习开发中我们要充分利用Pipeline提供的优势来降低开发成本从而把主要精力放在特征工程与模型调优上。
到此为止Spark MLlib模块的全部内容我们就讲完了。
在这个模块中我们主要围绕着特征工程、模型训练和机器学习流水线等几个方面梳理了Spark MLlib子框架为开发者提供的种种能力。换句话说我们知道了Spark MLlib能做哪些事情、擅长做哪些事情。如果我们能够做到对这些能力了如指掌在日常的机器学习开发中就可以灵活地对其进行取舍从而去应对不断变化的业务需求加油
每日一练
我们今天一直在讲Pipeline的优势你能说一说Pipeline有哪些可能的劣势吗
欢迎你在留言区和我交流互动也推荐你把这一讲分享给更多同事、朋友说不定就能让他进一步理解Pipeline。

View File

@ -0,0 +1,226 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 Structured Streaming从“流动的Word Count”开始
你好,我是吴磊。
从今天这一讲开始,我们将进入流计算的学习模块。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,因此流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。
在这个模块中按照惯例我们还是从一个可以迅速上手的实例开始带你初步认识Spark的流处理框架Structured Streaming。然后我们再从框架所提供的能力、特性出发深入介绍Structured Streaming工作原理、最佳实践以及开发注意事项等等。
在专栏的第一个模块我们一直围绕着Word Count在打转也就是通过从文件读取内容然后以批处理的形式来学习各式各样的数据处理技巧。而今天这一讲我们换个花样从一个“流动的Word Count”入手去学习一下在流计算的框架下Word Count是怎么做的。
环境准备
要上手今天的实例你只需要拥有Spark本地环境即可并不需要分布式的物理集群。
不过咱们需要以“流”的形式为Spark提供输入数据因此要完成今天的实验我们需要开启两个命令行终端。一个用于启动spark-shell另一个用于开启Socket端口并输入数据如下图所示。
流动的Word Count
环境准备好之后接下来我们具体来说一说什么是“流动的Word Count”。
所谓没有对比就没有鉴别为了说清楚“流动的Word Count”咱们不妨拿批处理版本的Word Count作对比。在之前的Word Count中数据以文件wikiOfSpark.txt的形式一次性地“喂给”Spark从而触发一次Job计算。而在“流动的Word Count”里数据以行为粒度分批地“喂给”Spark每一行数据都会触发一次Job计算。
具体来说我们使用netcat工具向本地9999端口的Socket地址发送数据行。而Spark流处理应用则时刻监听着本机的9999端口一旦接收到数据条目就会立即触发计算逻辑的执行。当然在我们的示例中这里的计算逻辑就是Word Count。计算执行完毕之后流处理应用再把结果打印到终端Console上。
与批处理不同只要我们不人为地中断流处理应用理论上它可以一直运行到永远。以“流动的Word Count”为例只要我们不强制中断它它就可以一直监听9999端口接收来自那里的数据并以实时的方式处理它。
好啦,弄清楚我们要做的事情之后,接下来,我们一起来一步一步地实现它。
首先第一步我们在第二个用来输入数据的终端敲入命令“nc -lk 9999”也就是使用netcat工具开启本机9999端口的Socket地址。一般来说大多数操作系统都预装了netcat工具因此不论你使用什么操作系统应该都可以成功执行上述命令。
命令敲击完毕之后光标会在屏幕上一直闪烁这表明操作系统在等待我们向Socket地址发送数据。我们暂且把它搁置在这里等一会流处理应用实现完成之后再来处理它。
接下来第二步我们从第一个终端进入spark-shell本地环境然后开始开发流处理应用。首先我们先导入DataFrame并指定应用所需监听的主机与端口号。
import org.apache.spark.sql.DataFrame
// 设置需要监听的本机地址与端口号
val host: String = "127.0.0.1"
val port: String = "9999"
数据加载
然后是数据加载环节我们通过SparkSession的readStream API来创建DataFrame。
// 从监听地址创建DataFrame
var df: DataFrame = spark.readStream
.format("socket")
.option("host", host)
.option("port", port)
.load()
仔细观察上面的代码你有没有觉得特别眼熟呢没错readStream API与SparkSession的read API看上去几乎一模一样。
可以看到与read API类似readStream API也由3类最基本的要素构成也就是
format指定流处理的数据源头类型
option与数据源头有关的若干选项
load将数据流加载进Spark
流计算场景中有3个重要的基础概念需要我们重点掌握。它们依次是Source、流处理引擎与Sink。其中Source是流计算的数据源头也就是源源不断地产生数据的地方。与之对应Sink指的是数据流向的目的地也就是数据要去向的地方后面我们讲到writeSteam API的时候再去展开。
而流处理引擎是整个模块的学习重点后续我们还会深入讨论。它的作用显而易见在数据流动过程中实现数据处理保证数据完整性与一致性。这里的数据处理包括我们Spark SQL模块讲过的各种操作类型比如过滤、投影、分组、聚合、排序等等。
现在让我们先把注意力放到readStream API与Source上来。通过readStream API的format函数我们可以指定不同类型的数据源头。在Structured Streaming框架下Spark主要支持3类数据源分别是Socket、File和Kafka。
其中Socket类型主要用于开发试验或是测试应用的连通性这也是这一讲中我们采用Socket作为数据源的原因。File指的是文件系统Spark可以通过监听文件夹把流入文件夹的文件当作数据流来对待。而在实际的工业级应用中Kafka + Spark的组合最为常见因此在本模块的最后我们会单独开辟一篇专门讲解Kafka与Spark集成的最佳实践。
通过format指定完数据源之后还需要使用零到多个option来指定数据源的具体地址、访问权限等信息。以咱们代码中的Socket为例我们需要明确主机地址与端口地址。
// 从监听地址创建DataFrame
var df: DataFrame = spark.readStream
.format("socket")
.option("host", host)
.option("port", port)
.load()
一切准备就绪之后我们就可以通过load来创建DataFrame从而把数据流源源不断地加载进Spark系统。
数据处理
有了DataFrame在手我们就可以使用之前学习过的各类DataFrame算子去实现Word Count的计算逻辑。这一步比较简单你不妨先自己动手试试然后再接着往下看。
/**
使用DataFrame API完成Word Count计算
*/
// 首先把接收到的字符串以空格为分隔符做拆分得到单词数组words
df = df.withColumn("words", split($"value", " "))
// 把数组words展平为单词word
.withColumn("word", explode($"words"))
// 以单词word为Key做分组
.groupBy("word")
// 分组计数
.count()
首先需要说明的是我们从Socket创建的DataFrame默认只有一个“value”列它以行为粒度存储着从Socket接收到数据流。比方说我们在第二个终端也就是netcat界面敲入两行数据分别是“Apache Spark”和“Spark Logo”。那么在“value”列中就会有两行数据与之对应同样是“Apache Spark”和“Spark Logo”。
对于“value”列我们先是用空格把它拆分为数组words然后再用explode把words展平为单词word接下来就是对单词word做分组计数。这部分处理逻辑比较简单你很容易就可以上手鼓励你尝试其他不同的算子来实现同样的逻辑。
数据输出
数据处理完毕之后与readStream API相对应我们可以调用writeStream API来把处理结果写入到Sink中。在Structured Streaming框架下Spark支持多种Sink类型其中有Console、File、Kafka和Foreach(Batch)。对于这几种Sink的差异与特点我们留到[下一讲]再去展开。
这里我们先来说说ConsoleConsole就是我们常说的终端选择Console作为SinkSpark会把结果打印到终端。因此Console往往与Socket配合用于开发实验与测试连通性代码实现如下所示。
/**
将Word Count结果写入到终端Console
*/
df.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定输出模式
.outputMode("complete")
//.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
可以看到writeStream API看上去与DataFrame的write API也是极为神似。
其中format用于指定Sink类型option则用于指定与Sink类型相关的输出选项比如与Console相对应的“truncate”选项用来表明输出内容是否需要截断。在write API中我们最终通过调用save把数据保持到指定路径而在writeStream API里我们通过start来启动端到端的流计算。
所谓端到端的流计算它指的就是我们在“流动的Word Count”应用中实现的3个计算环节也即从数据源不断地加载数据流以Word Count的计算逻辑处理数据并最终把计算结果打印到Console。
整个计算过程持续不断即便netcat端没有任何输入“流动的Word Count”应用也会一直运行直到我们强制应用退出为止。而这正是函数awaitTermination的作用顾名思义它的目的就是在“等待用户中断”。
对于writeStream API与write API的不同除了刚刚说的start和awaitTermination以外细心的你想必早已发现writeStream API多了一个outputMode函数它用来指定数据流的输出模式。
想要理解这个函数就要清楚数据流的输出模式都有哪些。我们先来说一说Structured Streaming都支持哪些输出模式然后再用“流动的Word Count”的执行结果来直观地进行对比说明。
一般来说Structured Streaming支持3种Sink输出模式也就是
Complete mode输出到目前为止处理过的全部内容
Append mode仅输出最近一次作业的计算结果
Update mode仅输出内容有更新的计算结果
当然这3种模式并不是在任何场景下都适用。比方说在我们“流动的Word Count”示例中Append mode就不适用。原因在于对于有聚合逻辑的流处理来说开发者必须要提供Watermark才能使用Append mode。
后面第32讲我们还会继续学习Watermark和Sink的三种输出模式这里你有个大致印象就好。
执行结果
到目前为止“流动的Word Count”应用代码已全部开发完毕接下来我们先让它跑起来感受一下流计算的魅力。然后我们再通过将outputMode中的“complete”替换为“update”直观对比一下它们的特点和区别。
要运行“流动的Word Count”首先第一步我们把刚刚实现的所有代码依次敲入第一个终端的spark-shell。全部录入之后等待一会你应该会看到如下的画面
当出现“Batch: 0”字样后这表明我们的流处理应用已经成功运行并在9999端口等待数据流的录入。接下来我们切换到第二个终端也就是开启netcat的终端界面然后依次逐行注意依次逐行输入下面的文本内容每行数据录入之间请间隔3~5秒。
然后我们再把屏幕切换到spark-shell终端你会看到Spark跑了4批作业执行结果分别如下。
可以看到在Complete mode下每一批次的计算结果都会包含系统到目前为止处理的全部数据内容。你可以通过对比每个批次与前面批次的差异来验证这一点。
接下来我们在spark-shell终端输入强制中断命令ctrl + D或ctrl + C退出spark-shell。然后再次在终端敲入“spark-shell”命令再次进入spark-shell本地环境并再次录入“流动的Word Count”代码。不过这一次在代码的最后我们把writeStream中的outputMode由原来的“complete”改为“update”。
代码录入完毕之后我们再切回到netcat终端并重新录入刚刚的4条数据然后观察第一个终端spark-shell界面的执行结果。
对比之下一目了然可以看到在Update mode下每个批次仅输出内容有变化的数据记录。所谓有变化也就是要么单词是第一次在本批次录入计数为1要么单词是重复录入计数有所变化。你可以通过观察不同批次的输出以及对比Update与Complete不同模式下的输出结果来验证这一点。
好啦到目前为止我们一起开发了一个流处理小应用“流动的Word Count”并一起查看了它在不同输出模式下的计算结果。恭喜你学到这里可以说你的一只脚已经跨入了Spark流计算的大门。后面还有很多精彩的内容有待我们一起去发掘让我们一起加油
重点回顾
今天这一讲你需要掌握如下几点。首先你需要熟悉流计算场景中3个重要的基本概念也就是Source、流处理引擎和Sink如下图所示。
再者对于Source与Sink你需要知道在Structured Streaming框架下Spark都能提供哪些具体的支持。以Source为例Spark支持Socket、File和Kafka而对于SinkSpark支持Console、File、Kafka和Foreach(Batch)。
之后我们结合一个流处理小应用借此熟悉了在Structured Streaming框架下流处理应用开发的一般流程。一般来说我们通过readStream API从不同类型的Source读取数据流、并创建DataFrame然后使用DataFrame算子处理数据如数据的过滤、投影、分组、聚合等最终通过writeStream API将处理结果写入到不同形式的Sink中去。
最后对于结果的输出我们需要了解在不同的场景下Structured Streaming支持不同的输出模式。输出模式主要有3种分别是Complete mode、Append mode和Update mode。其中Complete mode输出到目前为止处理过的所有数据而Update mode仅输出在当前批次有所更新的数据内容。
每课一练
在运行“流动的Word Count”的时候我们强调依次逐行输入数据内容请你把示例给出的4行数据一次性地输入netcat拷贝&粘贴然后观察Structured Streaming给出的结果与之前相比有什么不同
欢迎你在留言区跟我交流互动也推荐你把今天的内容分享给更多同事、朋友一起动手搭建这个Word Count流计算应用。

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 新一代流处理框架Batch mode和Continuous mode哪家强
你好,我是吴磊。
在上一讲我们通过“流动的Word Count”示例初步结识了Structured Streaming并学习了流处理开发三要素也就是Source、流处理引擎与Sink。
今天这一讲让我们把目光集中到Structured Streaming也就是流处理引擎本身。Structured Streaming与Spark MLlib并列是Spark重要的子框架之一。值得一提的是Structured Streaming天然能够享受Spark SQL提供的处理能力与执行性能同时也能与其他子框架无缝衔接。因此基于Structured Streaming这个新一代框架开发的流处理应用天然具备优良的执行性能与良好的扩展性。
知己知彼百战百胜。想要灵活应对不同的实时计算需求我们就要先了解Structured Streaming的计算模型长啥样搞清楚它如何应对容错、保持数据一致性。我们先从计算模型说起。
计算模型
当数据像水流一样源源不断地流进Structured Streaming引擎的时候引擎并不会自动地依次消费并处理这些数据它需要一种叫做Trigger的机制来触发数据在引擎中的计算。
换句话说Trigger机制决定了引擎在什么时候、以怎样的方式和频率去处理接收到的数据流。Structured Streaming支持4种Trigger如下表所示。
要为流处理设置Trigger我们只需基于writeStream API调用trigger函数即可。Trigger的种类比较多一下子深入细节容易让你难以把握重点所以现在你只需要知道Structured Streaming支持种类繁多的Trigger即可。
我们先把注意力放在计算模型上面。对于流数据Structured Streaming支持两种计算模型分别是Batch mode和Continuous mode。所谓计算模型本质上它要解决的问题就是Spark以怎样的方式来对待并处理流数据。
这是什么意思呢没有对比就没有鉴别咱们不妨通过对比讲解Batch mode和Continuous mode来深入理解计算模型的含义。
Batch mode
我们先来说说Batch mode所谓Batch mode它指的是Spark将连续的数据流切割为离散的数据微批Micro-batch也即小份的数据集。
形象一点说Batch mode就像是“抽刀断水”两刀之间的水量就是一个Micro-batch。而每一份Micro-batch都会触发一个Spark Job每一个Job会包含若干个Tasks。学习过基础知识与Spark SQL模块之后我们知道这些Tasks最终会交由Spark SQL与Spark Core去做优化与执行。
在这样的计算模型下不同种类的Trigger如Default、Fixed interval以及One-time无非是在以不同的方式控制Micro-batch切割的粒度罢了。
比方说在Default Trigger下Spark会根据数据流的流入速率自行决定切割粒度无需开发者关心。而如果开发者想要对切割粒度进行人为的干预则可以使用Fixed interval Trigger来明确定义Micro-batch切割的时间周期。例如Trigger.ProcessingTime(“5 seconds”)表示的是每隔5秒钟切割一个Micro-batch。
Continuous mode
与Batch mode不同Continuous mode并不切割数据流而是以事件/消息Event / Message为粒度用连续的方式来处理数据。这里的事件或是消息指代的是原始数据流中最细粒度的数据形式它可以是一个单词、一行文本或是一个画面帧。
以“流动的Word Count”为例Source中的事件/消息就是一个个英文单词。说到这里你可能会有疑问“在Batch mode下Structured Streaming不也是连续地创建Micro-batch吗数据同样是不丢不漏Continuous mode与Batch mode有什么本质上的区别吗
一图胜千言对比两种计算模型的示意图我们可以轻松地发现它们之间的差异所在。在Continuous mode下Structured Streaming使用一个常驻作业Long running job来处理数据流或者说服务中的每一条消息。
那么问题来了相比每个Micro-batch触发一个作业Continuous mode选择采用常驻作业来进行服务有什么特别的收益吗或者换句话说这两种不同的计算模型各自都有哪些优劣势呢
用一句话来概括Batch mode吞吐量大、延迟高秒级而Continuous mode吞吐量低、延迟也更低毫秒级。吞吐量指的是单位时间引擎处理的消息数量批量数据能够更好地利用Spark分布式计算引擎的优势因此Batch mode在吞吐量自然更胜一筹。
而要回答为什么Continuous mode能够在延迟方面表现得更加出色我们还得从Structured Streaming的容错机制说起。
容错机制
对于任何一个流处理引擎来说,容错都是一项必备的能力。所谓容错,它指的是,在计算过程中出现错误(作业层面、或是任务层面,等等)的时候,流处理引擎有能力恢复被中断的计算过程,同时保证数据上的不重不漏,也即保证数据处理的一致性。
从数据一致性的角度出发这种容错的能力可以划分为3种水平
At most once最多交付一次数据存在丢失的风险
At least once最少交付一次数据存在重复的可能
Exactly once交付且仅交付一次数据不重不漏。
这里的交付指的是数据从Source到Sink的整个过程。对于同一条数据它可能会被引擎处理一次或在有作业或是任务失败的情况下多次但根据容错能力的不同计算结果最终可能会交付给Sink零次、一次或是多次。
聊完基本的容错概念之后我们再说回Structured Streaming。就Structured Streaming的容错能力来说Spark社区官方的说法是“结合幂等的SinkStructured Streaming能够提供Exactly once的容错能力”。
实际上这句话应该拆解为两部分。在数据处理上结合容错机制Structured Streaming本身能够提供“At least once”的处理能力。而结合幂等的SinkStructured Streaming可以实现端到端的“Exactly once”容错水平。
比方说应用广泛的Kafka在Producer级别提供跨会话、跨分区的幂等性。结合Kafka这样的Sink在端到端的处理过程中Structured Streaming可以实现“Exactly once”保证数据的不重不漏。
不过,在 Structured Streaming 自身的容错机制中为了在数据处理上做到“At least once”Batch mode 与 Continuous mode 这两种不同的计算模型,分别采用了不同的实现方式。而容错实现的不同,正是导致两种计算模型在延迟方面差异巨大的重要因素之一。
接下来我们就来说一说Batch mode 与 Continuous mode 分别如何做容错。
Batch mode容错
在Batch mode下Structured Streaming利用Checkpoint机制来实现容错。在实际处理数据流中的Micro-batch之前Checkpoint机制会把该Micro-batch的元信息全部存储到开发者指定的文件系统路径比如HDFS或是Amazon S3。这样一来当出现作业或是任务失败时引擎只需要读取这些事先记录好的元信息就可以恢复数据流的“断点续传”。
要指定Checkpoint目录只需要在writeStream API的option选项中配置checkpointLocation即可。我们以上一讲的“流动的Word Count”为例代码只需要做如下修改即可。
df.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定Checkpoint存储地址
.option("checkpointLocation", "path/to/HDFS")
// 指定输出模式
.outputMode("complete")
//.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
在Checkpoint存储目录下有几个子目录分别是offsets、sources、commits和state它们所存储的内容就是各个Micro-batch的元信息日志。对于不同子目录所记录的实际内容我把它们整理到了下面的图解中供你随时参考。
-
对于每一个Micro-batch来说在它被Structured Streaming引擎实际处理之前Checkpoint机制会先把它的元信息记录到日志文件因此这些日志文件又被称为Write Ahead LogWAL日志
换句话说当源数据流进Source之后它需要先到Checkpoint目录下进行“报道”然后才会被Structured Streaming引擎处理。毫无疑问“报道”这一步耽搁了端到端的处理延迟如下图所示。
除此之外由于每个Micro-batch都会触发一个Spark作业我们知道作业与任务的频繁调度会引入计算开销因此也会带来不同程度的延迟。在运行模式与容错机制的双重加持下Batch mode的延迟水平往往维持在秒这个量级在最好的情况下能达到几百毫秒左右。
Continuous mode容错
相比Batch modeContinuous mode下的容错没那么复杂。在Continuous mode下Structured Streaming利用Epoch Marker机制来实现容错。
因为Continuous mode天然没有微批所以不会涉及到微批中的延迟到达Source中的消息可以立即被Structured Streaming引擎消费并处理。但这同时也带来一个问题那就是引擎如何把当前的处理进度做持久化从而为失败重试提供可能。
为了解决这个问题Spark引入了Epoch Marker机制。所谓Epoch Marker你可以把它理解成是水流中的“游标”这些“游标”随着水流一起流动。每个游标都是一个Epoch Marker而游标与游标之间的水量就是一个Epoch开发者可以通过如下语句来指定Epoch间隔。
writeStream.trigger(continuous = "1 second")
以表格中的代码为例对于Source中的数据流Structured Streaming每隔1秒就会安插一个Epoch Marker而两个Epoch Marker之间的数据就称为一个Epoch。你可能会问“Epoch Marker的概念倒是不难理解不过它有什么用呢
在引擎处理并交付数据的过程中每当遇到Epoch Marker的时候引擎都会把对应Epoch中最后一条消息的Offset写入日志从而实现容错。需要指出的是日志的写入是异步的因此这个过程不会对数据的处理造成延迟。
有意思的是对于这个日志的称呼网上往往也把它叫作Write Ahead Log。不过我觉得这么叫可能不太妥当原因在于准备写入日志的消息都已经被引擎消费并处理过了。Batch mode会先写日志、后处理数据而Continuous mode不一样它是先处理数据、然后再写日志。所以把Continuous mode的日志称作是“Write After Log”也许更合适一些。
我们还是用对比的方法来加深理解接下来我们同样通过消息到达Source与Structured Streaming引擎的时间线来示意Continuous mode下的处理延迟。
可以看到消息从Source产生之后可以立即被Structured Streaming引擎消费并处理因而在延迟性方面能够得到更好的保障。而Epoch Marker则会帮助引擎识别当前最新处理的消息从而把相应的Offset记录到日志中以备失败重试。
重点回顾
到此为止,今天的内容就全部讲完了,我们一起来做个总结。
今天这一讲我们学习了Structured Streaming中两种不同的计算模型——Batch mode与Continuous mode。只有了解了它们各自在吞吐量、延迟性和容错等方面的特点在面对日常工作中不同的流计算场景时我们才能更好地做出选择。
在Batch mode下Structured Streaming会将数据流切割为一个个的Micro-batch。对于每一个Micro-batch引擎都会创建一个与之对应的作业并将作业交付给Spark SQL与Spark Core付诸优化与执行。
Batch mode的特点是吞吐量大但是端到端的延迟也比较高延迟往往维持在秒的量级。Batch mode的高延迟一方面来自作业调度本身一方面来自它的容错机制也就是Checkpoint机制需要预写WALWrite Ahead Log日志。
要想获得更低的处理延迟你可以采用Structured Streaming的Continuous mode计算模型。在Continuous mode下引擎会创建一个Long running job来负责消费并服务来自Source的所有消息。
在这种情况下Continuous mode天然地避开了频繁生成、调度作业而引入的计算开销。与此同时利用Epoch Marker通过先处理数据、后记录日志的方式Continuous mode进一步消除了容错带来的延迟影响。
尺有所短、寸有所长Batch mode在吞吐量上更胜一筹而Continuous mode在延迟性方面则能达到毫秒级。
不过需要特别指出的是到目前为止在Continuous mode下Structured Streaming仅支持非聚合Aggregation类操作比如map、filter、flatMap等等。而聚合类的操作比如“流动的Word Count”中的分组计数Continuous mode暂时是不支持的这一点难免会限制Continuous mode的应用范围需要你特别注意。
每课一练
Batch mode通过预写WAL日志来实现容错请你脑洞一下有没有可能参考Continuous mode中先处理数据、后记录日志的方式把Batch mode中写日志的动作也挪到数据消费与处理之后呢
欢迎你在留言区跟我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 流计算中的数据关联:流与流、流与批
你好,我是吴磊。
在上一讲我们提到Structured Streaming会复用Spark SQL所提供的一切数据处理能力比如数据抽取、过滤、分组聚合、关联、排序等等。不过在这些常规的数据处理类型中有一类操作需要我们特别关注它就是数据关联Joins
这主要是出于两方面的原因,一来,数据关联的应用非常普遍,可以说是数据应用中“出场率”最高的操作类型之一;再者,与批处理中的数据关联不同,流计算中的数据关联,还需要考虑到流处理过程中固有的一些限制,比如说时间窗口、数据延迟容忍度、输出模式,等等。
因此今天这一讲我们专门来说一说Structured Streaming中的数据关联。我们先盘点好Structured Streaming的技能树看看它都支持哪些种类的数据关联。之后再用一个短视频推荐的例子上手试验一下总结出不同类型数据关联的适用场景以及注意事项。
流计算中的数据关联
我们知道如果按照关联形式来划分的话数据关联可以分为Inner Join、Left Join、Right Join、Semi Join、Anti Join等等。如果按照实现方式来划分的话可以分为Nested Loop Join、Sort Merge Join和Hash Join。而如果考虑分布式环境下数据分发模式的话Join又可以分为Shuffle Join和Broadcast Join。
对于上述的3种分类标准它们之间是相互正交的我们在Spark SQL学习模块介绍过它们各自的适用场景与优劣势记不清的可以回顾第[17]、[18]讲)。
而在流计算的场景下按照数据来源的不同数据关联又可以分为“流批关联”与“双流关联”。所谓“流批关联”Stream-Static Join它指的是参与关联的一张表来自离线批数据而另一张表的来源是实时的数据流。换句话说动态的实时数据流可以与静态的离线数据关联在一起为我们提供多角度的数据洞察。
而“双流关联”Stream-Stream Join顾名思义它的含义是参与关联的两张表都来自于不同的数据流属于动态数据与动态数据之间的关联计算如下图所示。
显然相对于关联形式、实现方式和分发模式数据来源的分类标准与前三者也是相互正交的。我们知道基于前3种分类标准数据关联已经被划分得足够细致。再加上一种正交的分类标准数据关联的划分只会变得更为精细。
更让人头疼的是在Structured Streaming流计算框架下“流批关联”与“双流关联”对于不同的关联形式有着不同的支持与限制。而这也是我们需要特别关注流处理中数据关联的原因之一。
接下来,我们就分别对“流批关联”和“双流关联”进行展开,说一说它们支持的功能与特性,以及可能存在的限制。本着由简入难的原则,我们先来介绍“流批关联”,然后再去说“双流关联”。
流批关联
为了更好地说明流批关联,咱们不妨从一个实际场景入手。在短视频流行的当下,推荐引擎扮演着极其重要的角色,而要想达到最佳的推荐效果,推荐引擎必须依赖用户的实时反馈。
所谓实时反馈,其实就是我们习以为常的点赞、评论、转发等互动行为,不过,这里需要突出的,是一个“实时性”、或者说“及时性”。毕竟,在选择越来越多的今天,用户的兴趣与偏好,也在随着时间而迁移、变化,捕捉用户最近一段时间的兴趣爱好更加重要。
假设现在我们需要把离线的用户属性和实时的用户反馈相关联从而建立用户特征向量。显然在这个特征向量中我们既想包含用户自身的属性字段如年龄、性别、教育背景、职业等等更想包含用户的实时互动信息比如1小时内的点赞数量、转发数量等等从而对用户进行更为全面的刻画。
一般来说,实时反馈来自线上的数据流,而用户属性这类数据,往往存储在离线数据仓库或是分布式文件系统。因此,用户实时反馈与用户属性信息的关联,正是典型的流批关联场景。
那么,针对刚刚说的短视频场景,我们该如何把离线用户属性与线上用户反馈“合二为一”呢?为了演示流批关联的过程与用法,咱们自然需要事先把离线数据与线上数据准备好。本着一切从简的原则,让你仅用笔记本电脑就能复现咱们课程中的实例,这里我们使用本地文件系统来存放离线的用户属性。
而到目前为止对于数据流的生成我们仅演示过Socket的用法。实际上除了用于测试的Socket以外Structured Streaming还支持Kafka、文件等Source作为数据流的来源。为了尽可能覆盖更多知识点这一讲咱们不妨通过文件的形式来模拟线上的用户反馈。
还记得吗Structured Streaming通过readStream API来创建各式各样的数据流。要以文件的方式创建数据流我们只需将文件格式传递给format函数然后启用相应的option即可如下所示。关于readStream API的一般用法你可以回顾“流动的Word Count”[第30讲])。
var streamingDF: DataFrame = spark.readStream
.format("csv")
.option("header", true)
.option("path", s"${rootPath}/interactions")
.schema(actionSchema)
.load
对于这段代码片段来说需要你特别注意两个地方。一个是format函数它的形参是各式各样的文件格式如CSV、Parquet、ORC等等。第二个地方是指定监听地址的option选项也就是option(“path”, s”${rootPath}/interactions”)。
该选项指定了Structured Streaming需要监听的文件系统目录一旦有新的数据内容进入该目录Structured Streaming便以流的形式把新数据加载进来。
需要说明的是上面的代码并不完整目的是让你先对文件形式的Source建立初步认识。随着后续讲解的推进待会我们会给出完整版代码并详细讲解其中的每一步。
要用文件的形式模拟数据流的生成我们只需将包含用户互动行为的文件依次拷贝到Structured Streaming的监听目录即可在我们的例子中也就是interactions目录。
如上图的步骤1所示我们事先把用户反馈文件保存到临时的staging目录中然后依次把文件拷贝到interactions目录即可模拟数据流的生成。而用户属性信息本身就是离线数据因此我们把相关数据文件保存到userProfile目录即可如图中步骤3所示。
对于上面的流批关联计算过程在给出代码实现之前咱们不妨先来了解一下数据从而更好地理解后续的代码内容。离线的用户属性比较简单仅包含id、name、age与gender四个字段文件内容如下所示。
线上的用户反馈相对复杂一些分别包含userId、videoId、event、eventTime等字段。前两个字段分别代表用户ID与短视频ID而event是互动类型包括Like点赞、Comment评论、Forward转发三个取值eventTime则代表产生互动的时间戳如下所示。
除了上面的interactions0.csv以外为了模拟数据流的生成我还为你准备了interactions1.csv、interactions2.csv两个文件它们的Schema与interactions0.csv完全一致内容也大同小异。对于这3个文件我们暂时把它们缓存在staging目录下。
好啦数据准备好之后接下来我们就可以从批数据与流数据中创建DataFrame并实现两者的关联达到构建用户特征向量的目的。首先我们先来加载数据。
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.types.StructType
// 保存staging、interactions、userProfile等文件夹的根目录
val rootPath: String = _
// 使用read API读取离线数据创建DataFrame
val staticDF: DataFrame = spark.read
.format("csv")
.option("header", true)
.load(s"${rootPath}/userProfile/userProfile.csv")
// 定义用户反馈文件的Schema
val actionSchema = new StructType()
.add("userId", "integer")
.add("videoId", "integer")
.add("event", "string")
.add("eventTime", "timestamp")
// 使用readStream API加载数据流注意对比readStream API与read API的区别与联系
var streamingDF: DataFrame = spark.readStream
// 指定文件格式
.format("csv")
.option("header", true)
// 指定监听目录
.option("path", s"${rootPath}/interactions")
// 指定数据Schema
.schema(actionSchema)
.load
为了方便你把代码与计算流程对应上这里我再一次把流批关联示意图贴在了下面。上述代码对应的是下图中的步骤2与步骤3也就是流数据与批数据的加载。
从代码中我们不难发现readStream API与read API的用法几乎如出一辙不仅如此二者的返回类型都是DataFrame。因此流批关联在用法上与普通的DataFrame之间的关联看上去并没有什么不同如下所示。
// 互动数据分组、聚合对应流程图中的步骤4
streamingDF = streamingDF
// 创建Watermark设置最大容忍度为30分钟
.withWatermark("eventTime", "30 minutes")
// 按照时间窗口、userId与互动类型event做分组
.groupBy(window(col("eventTime"), "1 hours"), col("userId"), col("event"))
// 记录不同时间窗口,用户不同类型互动的计数
.count
/**
流批关联对应流程图中的步骤5
可以看到与普通的两个DataFrame之间的关联看上去没有任何差别
*/
val jointDF: DataFrame = streamingDF.join(staticDF, streamingDF("userId") === staticDF("id"))
除了在用法上没有区别以外普通DataFrame数据关联中适用的优化方法同样适用于流批关联。比方说对于streamingDF来说它所触发的每一个Micro-batch都会扫描一次staticDF所封装的离线数据。
显然在执行效率方面这并不是一种高效的做法。结合Spark SQL模块学到的Broadcast Join的优化方法我们完全可以在staticDF之上创建广播变量然后把流批关联原本的Shuffle Join转变为Broadcast Join来提升执行性能。这个优化技巧仅涉及几行代码的修改因此我把它留给你作为课后作业去练习。
完成流批关联之后我们还需要把计算结果打印到终端Console是Structured Streaming支持的Sink之一它可以帮我们确认计算结果与预期是否一致如下所示。
jointDF.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定输出模式
.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
上面这段代码想必你并不陌生咱们在之前的几讲中都是指定Console为输出Sink这里的操作没什么不同。
好啦到此为止流批关联实例的完整代码就是这些了。接下来让我们把代码敲入本地环境的spark-shell然后依次把staging文件夹中的interactions*.csv拷贝到interactions目录之下来模拟数据流的生成从而触发流批关联的计算。代码与数据的全部内容你可以通过这里的GitHub地址进行下载。
这里我贴出部分计算结果供你参考。下面的截图是我们把interactions0.csv文件拷贝到interactions目录之后得到的结果你可以在你的环境下去做验证同时继续把剩下的两个文件拷贝到监听目录来进一步观察流批关联的执行效果。
双流关联
了解了流批关联之后,我们再来说说“双流关联”。显然,与流批关联相比,双流关联最主要的区别是数据来源的不同。除此之外,在双流关联中,事件时间的处理尤其关键。为什么这么说呢?
学过上一讲之后我们知道在源源不断的数据流当中总会有Late Data产生。Late Data需要解决的主要问题就是其是否参与当前批次的计算。
毫无疑问数据关联是一种最为常见的计算。因此在双流关联中我们应该利用Watermark机制明确指定两条数据流各自的Late Data“容忍度”从而避免Structured Streaming为了维护状态数据而过度消耗系统资源。Watermark的用法很简单你可以通过回顾[上一讲]来进行复习。
说到这里,你可能会问:“什么是状态数据?而维护状态数据,又为什么会过度消耗系统资源呢?”一图胜千言,咱们不妨通过下面的示意图,来说明状态数据的维护,会带来哪些潜在的问题和隐患。
假设咱们有两个数据流一个是短视频发布的数据流其中记录着短视频相关的元信息如ID、Name等等。另一个数据流是互动流也就是用户对于短视频的互动行为。其实在刚刚的流批关联例子中我们用到数据流也是互动流这个你应该不会陌生。
现在我们想统计短视频在发布一段时间比如1个小时、6个小时、12个小时等等之后每个短视频的热度。所谓热度其实就是转评赞等互动行为的统计计数。
要做到这一点咱们可以先根据短视频ID把两个数据流关联起来然后再做统计计数。上图演示的是两条数据流在Micro-batch模式下的关联过程。为了直击要点咱们把注意力放在ID=1的短视频上。
显然在视频流中短视频的发布有且仅有一次即便是内容完全相同的短视频在数据的记录上也会有不同的ID值。而在互动流中ID=1的数据条目会有多个而且会分布在不同的Micro-batch中。事实上只要视频没有下线随着时间的推移互动流中总会夹带着ID=1的互动行为数据。
为了让视频流中ID=1的记录能够与互动流的数据关联上我们需要一直把视频流中批次0的全部内容缓存在内存中从而去等待“迟到”的ID=1的互动流数据。像视频流这种为了后续计算而不得不缓存下来的数据我们就把它称作为“状态数据”。显然状态数据在内存中积压的越久、越多内存的压力就越大。
在双流关联中除了要求两条数据流要添加Watermark机之外为了进一步限制状态数据的尺寸Structured Streaming还要求在关联条件中对于事件时间加以限制。这是什么意思呢咱们还是结合视频流与互动流的示例通过代码来解读。
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.types.StructType
// 保存staging、interactions、userProfile等文件夹的根目录
val rootPath: String = _
// 定义视频流Schema
val postSchema = new StructType().add("id", "integer").add("name", "string").add("postTime", "timestamp")
// 监听videoPosting目录以实时数据流的方式加载新加入的文件
val postStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/videoPosting").schema(postSchema).load
// 定义Watermark设置Late data容忍度
val postStreamWithWatermark = postStream.withWatermark("postTime", "5 minutes")
// 定义互动流Schema
val actionSchema = new StructType().add("userId", "integer").add("videoId", "integer").add("event", "string").add("eventTime", "timestamp")
// 监听interactions目录以实时数据流的方式加载新加入的文件
val actionStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/interactions").schema(actionSchema).load
// 定义Watermark设置Late data容忍度
val actionStreamWithWatermark = actionStream.withWatermark("eventTime", "1 hours")
// 双流关联
val jointDF: DataFrame = actionStreamWithWatermark
.join(postStreamWithWatermark,
expr("""
// 设置Join Keys
videoId = id AND
// 约束Event time
eventTime >= postTime AND
eventTime <= postTime + interval 1 hour
"""))
代码的前两部分比较简单分别是从监听文件夹读取新增的文件内容依次创建视频流和互动流并在两条流上设置Watermark机制。这些内容之前已经学过不再重复咱们把重点放在最后的双流关联代码上。
可以看到在关联条件中除了要设置关联的主外键之外还必须要对两张表各自的事件时间进行约束。其中postTime是视频流的事件时间而eventTime是互动流的事件时间。上述代码的含义是对于任意发布的视频流我们只关心它一小时以内的互动行为一小时以外的互动数据将不再参与关联计算。
这样一来在Watermark机制的“保护”之下事件时间的限制进一步降低了状态数据需要在内存中保存的时间从而降低系统资源压力。简言之对于状态数据的维护有了Watermark机制与事件时间的限制可谓是加了“双保险”。
重点回顾
好啦到这里我们今天的内容就讲完啦咱们一起来做个总结。首先我们要知道根据数据流的来源不同Structured Streaming支持“流批关联”和“双流关联”两种关联模式。
流批关联统一了流处理与批处理二者的统一使得Structured Streaming有能力服务于更广泛的业务场景。流批关联的用法相对比较简单通过readStream API与read API分别读取实时流数据与离线数据然后按照一般Join语法完成数据关联。
在今天的演示中我们用到了File这种形式的Source你需要掌握File Source的一般用法。具体来说你需要通过readStream API的format函数来指定文件格式然后通过option指定监听目录。一旦有新的文件移动到监听目录Spark便以数据流的形式加载新数据。
对于双流关联来说我们首先需要明白在这种模式下Structured Streaming需要缓存并维护状态数据。状态数据的维护主要是为了保证计算逻辑上的一致性。为了让满足条件的Late data同样能够参与计算Structured Streaming需要一直在内存中缓存状态数据。毫无疑问状态数据的堆积会对系统资源带来压力与隐患。
为了减轻这样的压力与隐患在双流关联中一来我们应该对参与关联的两条数据流设置Watermark机制再者在语法上Structured Streaming在关联条件中会强制限制事件时间的适用范围。在这样的“双保险”机制下开发者即可将状态数据维护带来的性能隐患限制在可控的范围内从而在实现业务逻辑的同时保证应用运行稳定。
课后练习题
今天的题目有两道。
第一道题目是我在流批关联那里用interactions0.csv文件给你演示了数据关联操作/请你动手在你的环境下去做验证同时继续把剩下的两个文件interactions1.csv、interactions2.csv两个文件拷贝到监听目录来进一步观察流批关联的执行效果。
第二道题目是在双流关联中我们需要Watermark和关联条件来同时约束状态数据维护的成本与开销。那么在流批关联中我们是否也需要同样的约束呢为什么
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。

View File

@ -0,0 +1,91 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 小王:保持空杯心态,不做井底之蛙
你好我是小王是一名大数据开发者目前在一家通信运营商公司从事开发与运维工作从业已4年有余。
从我的经验看,如果某位工程师从事的不是大数据底层开发或顶层架构,而是业务开发,那么这位工程师所用到的大数据框架,主要分为三大块:数据采集、数据存储、数据计算。
而这些框架中出镜率很高生态发展很繁荣而且工作中常用面试中常问的框架Apache Spark也必然数一数二。
我之前是如何学习Spark的
作为平平无奇的普通开发者,我平时学习新东西的套路,总结起来就是三板斧:第一步,先请教老员工这个框架在架构图中的所处位置和核心作用;第二步,去网上找入门视频或博客资料,快速刷完,在心中描绘出一个大致轮廓,做到心中有数;第三步,去官网细读文档,跟着文档写代码,在代码中梳理开发流程和弄清细节。
我之前学习Spark也是用这种三板斧的思路来学习的。正如专栏的[开篇词]所说我在“经过短短3个月的强化练习之后已经能够独当一面熟练地实现各式各样的业务需求。而这自然要归功于 Spark 框架本身超高的开发效率”。
到这里我自认为我已经是一名Spark初级工程师了。在我通读了Spark官网文档并付诸代码后我甚至以Spark中级工程师自居了。
最近我通过考核,成为了公司的内训师,公司为了实现经验沉淀和知识共享,内训师们需要录制结合公司业务的实战课程。
为了不误人子弟我意识到自己还得好好巩固下自己的Spark知识。这里不得不说到前面三板斧的第二步也就是刷资料这步是相对坎坷的。尽管网上的资料林林总总内容虽多但“天下文章一大抄”不同的博客网站里总能看到一模一样的文章。有的文章作者水平一般讲得读者昏昏欲睡事小给读者灌输了错误知识事大。
所以在这个过程中,想找到优质的资料相对较难,费力劳心。幸运的是遇到了这个专栏,其中的内容也超出了我的预期,给我带来了很多启发。
学习专栏有什么收获?
在仔细研读了《零基础入门Spark》专栏后我才发现我错得离谱我可能离“初级”都还差得远呢。在阅读此专栏的过程中“这就触及我的知识盲区了”这个表情包不停地在我脑海中闪现。
天呐,发现自己的盲区让我心中一紧,感叹“基础不牢,地动山摇”。
因为我从来没有思考过RDD与数组的异同而将新知识与熟悉的知识做对比往往是get新知识的捷径我也从来没有将算子分门别类地分组整理过其实做好整理可以让你在开发时不假思索、信手拈来我也从来没试过对RDD的重要属性DAG计算流图做生活化联想而这个技巧可以延长我们的记忆曲线尤其适合记忆这类概念性知识点……
通过这个专栏这些从没深入思考过的点逐渐被点亮。除了新知识的理解Spark的几大核心系统也相当重要。
比如,调度系统的流转过程及其三大组件的各自职责,这部分内容掌握了,我们才能把握住分布式计算的精髓。再比如说内存、存储系统,把这些组件吃透,也正好是我们写出高性能代码的重要前提。
要想自己独自弄明白这些重要的点,最直接的方法自然是阅读源码。但是对于资质平平无奇的我来说,阅读源码可谓是“蜀道难”。不过面对这样的困难,这个专栏刚好提供了很有效的学习线索,仿佛武当梯云纵,让我们更有可能登高望远。
在这个专栏里吴老师并没有像其他课程那样按照Spark的模块按部就班地讲述而是通过一个入门case去将底层知识串联起来讲述以高屋建瓴之势述底层架构之蓝图。别担心听不懂因为吴老师对这些知识点做了生活化联想对应到工厂流水线、对应到建筑工地寓教于乐。
不要小看类比、联想的威力,相比干涩的名词,生活化联想可以有效规避死记硬背,让你出口成章,口吐莲花;关键是能让你理解更透彻,达成“既见树木又见森林”中的“见森林”。
有了“见森林”的底子后,当我再去阅读源码时,因为心里有了一条相对清晰的线索,按图索骥,所以知道哪里该重点阅读,哪里是里程碑,也不再惧怕阅读源码这件事了。
不知道你听没听过诺贝尔物理学奖获得者费曼的学习理论,也就是大名鼎鼎的费曼学习法,其中一个步骤是“用最简单的语言把一件事讲清楚,简单到小朋友也能听得懂”。而生活化联想的学习方式,也恰好与此学习方法的理念不谋而合。
在学习《零基础入门Spark》这个专栏的过程中我有一个小小的感悟相对于真正0基础的同学来说我认为有经验的同学反而可能会在学习的过程中更难一点。因为他的脑海中可能对某些知识点已经建立了刻板印象或错误认知遇到冲突的时候得先清空脑海中的既有知识。这好比得先清空自己杯子里的茶水才能接纳老禅师斟的新鲜茶水。
我是怎样学习专栏的?
在我看来,学习方法只是手段,把知识学到手才是目的。这里我聊聊我的个人经验,仅供参考。
吴老师讲得再清楚,知识也是吴老师的。所以我在学习的过程中,一直坚持自己写笔记,做好自己的内化。由于资质平平无奇,一篇文章我得阅读三四遍甚至更多,才能领会文章的思想。
我的具体操作如下:
第一遍,逐字仔细阅读,遇到问题我会努力克制住“马上去搜索、提问”的坏毛病,坚持把第一遍读完,建立大纲。
第二遍,带着大纲和问题再次阅读文章和评论区,说不定答案就藏在被我忽视的上下文的细节中,也说不定,会在评论区看到其他同学有着相似的疑问及大家的讨论交流(顺便说一句,评论区可是拓展认知边界的好地方,正所谓他山之石可以攻玉)。
第三遍,把标题抄下来,关掉文章,看自己能否对着标题,把相关的知识点罗列出来,以及每个知识点需要注意的事项。
文稿后面的内容来自我的学习笔记供你做个参考。这三张图分别梳理了调度系统、并行度与并行任务以及Spark存储相关的知识网络。
其实画图也好,记录笔记也罢,关键就是帮助自己把知识之间的逻辑关系建立起来。如果你在整理过程中遇到卡壳的地方,不妨再去阅读课程和官网资料查漏补缺。
在这样的学习、消化梳理之后,我还会将这些知识落到写代码上,或者跟读源码上,避免纸上谈兵。三四遍下来,“既见树木又见森林”中的“见树木”这个小目标也达成了。
对普通人来说事业成功的原因99%以上源于work with great people。吴老师就是这位the great people这个专栏就是the great thing。我很庆幸阅读了吴老师的这门课程。把好东西牢牢抱在怀里的那种感觉你知道吗这么好的东西咱可不能暴殄天物。
俗话说“最好的偷懒方式就是不偷懒”无数次的经验告诉我们偷过的懒都会加倍还回来。既然精进Spark是大数据工程师躲不掉的事情那么咱们就踏踏实实、按部就班地学习、行动起来吧。
纸上得来终觉浅,绝知此事要躬行。只有“躬行”了,专栏里的知识才会缓缓流进你的大脑里,当你用双手在键盘辛勤耕耘的时候,再从你飞舞的指尖上流出,编织成优雅美丽的代码。
保持空杯心态,不做井底之蛙。希望我们可以一起精进技术,学以致用,加油!

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 进入时间裂缝,持续学习
你好,我是吴磊。
时间过得真快,不知不觉,就到了要和你说再见的时候。首先要感谢你们的支持和陪伴,坦白地说,现在的我,有些恍惚,不敢相信专栏已经结束了。
从7月份开始筹备《零基础入门Spark》这门专栏以来赶稿子、改稿子、录制音频、回复留言这些任务已经变成了我每天的日常。回忆起这4个月的经历我脑海中闪现出的第一个词语就是“夹缝中求生存”。
为什么说是夹缝中求生存呢?作为一名有家庭的中年职场男来说,工作与家庭的双重“夹击”,让原本就有限的时间和精力变得更加“捉襟见肘”。工作的重要性不言而喻,它是我们个人发展的基础,自然需要全力以赴。而咱们国人也讲究“家事如天”,所以家里再小的事情,优先级也远超任何其他事情。
毫无疑问一天下来工作与家庭就占据了大部分时间。这样算下来如果把时间比作是一面墙的话那么一天24小时留给我专心写专栏的时间就像是墙上的一道夹缝或是一道裂缝。
记忆最深刻的是11月初的那两周。由于工作的原因党政机关妻子需要集中封闭两周她和我们处于完全失联的状态。那么自然照顾娃的生活起居的“重任”就落到了我的肩上。在“闭关”前妻子甚至特意为我这个“大老粗”列出了一份详细的清单上面洋洋洒洒地记录着每一天的日常。还没生儿育女的同学就别看了容易劝退。
古语云:“取乎其上,得乎其中;取乎其中,得乎其下”。看到妻子列出的这份“取乎其上”的清单,我就知道,以我对于闺女脾气的了解、以及我那粗线条的性格,我一定会把这份清单执行得“得乎其下”。
原因很简单这份清单的最佳候选人应当是全职奶爸而我显然并不是。因此在执行层面免不了要打折扣。我只好围绕着“让娃吃饱、穿暖、不着急、不生气、不生病”的大原则尽可能地参考妻子给出的Best Practices来个“曲线救国”。
举例来说,为了让娃睡个安稳觉,也为了我自己能早点赶稿子,我只好祭出独创的“扛娃入睡”大法。我会扛着她在屋子里左晃右晃、溜达来溜达去,最后小心地把她移到床上。
说真的,保证娃儿在哄睡过程中不被上下文(体感温度,光线变化,声波抖动等)的切换惊醒,是一件比拆装炸弹还要精细的作业。孩子是天生的多功能传感器,能够捕获外界多种信号源,而且捕获信号后她回调什么函数(仅仅翻个身,还是哭喊出来)来响应你,全看造化。
哄睡是个技术活儿,更是个体力活。这之后,我基本上已经是腰酸、背痛、腿抽筋,就差瘫倒在地上了。可问题是,时间都被工作和带娃占据了,稿子什么时候写呢?
熬夜的方案看似可行,牺牲睡眠时间,来赶稿子。但是,我不敢这么做,并不是我不肯吃熬夜的苦,而是我要保证内容生产的质量,而且也担心第二天因为精力涣散、在送娃的路上有所闪失。
所以在多个重要且紧急的事情同时压在身上的时候,我会更加注重睡眠质量,只有保持精力充沛,才有可能“多进程工作”。
在常规时间被占满的情况下,我只好钻进时间裂缝,也就是利用零散时间完成片段。对我来说,能利用的碎片化时间,就是上下班的地铁通勤。从家到公司,每天来回大概要两个小时,除去换乘的时间,满打满算,还剩一个多小时让我可以用拇指在手机上码字。
尽管地铁上的环境嘈杂而又拥挤不过我发现人在压力之下反而更容易专注那段时间我每天在地铁上都能码出800字左右的片段。
为了让你更轻松地学会Spark我还会主动思考有什么生活化的比喻或是类比。专栏里工地搬砖、斯巴克集团的故事以及玻璃杯牛奶等等例子不少都来自通勤时的灵光一闪。到了晚上或是周末我会把一周积累的片段系统化地进行整理、配图、配代码、加注释并最终编辑成一篇完整的文稿。
现代人的工作和生活节奏都很快,我们的时间被切割得不成样子。人们总是拿时间过于细碎作为拒绝学习的理由:“上下班通勤不过 2 个小时,中间还要换乘几次,思路总被打断,根本没法集中注意力学什么东西,还不如刷刷视频呢!”
然而实际上,系统化的知识体系与碎片化的内容摄取,并不冲突。构建知识体系,确实需要大段的、集中的时间,但是一旦建立,体系内的一个个知识点,完全可以利用碎片化的 20-30 分钟来搞定——番茄时间以 25 分钟为单位还是有科学依据的。
以Spark MLlib为例经过那个模块的学习想必你会觉得Spark MLlib支持的特征处理函数和模型算法好多啊数量多到让人想从入门到放弃的地步。但是在一番梳理之下我们不难发现不同的特征处理函数也好模型算法也好它们都可以被归类到某一个范畴中去。至于不同的类别之间的区别与联系咱们在课程中都做了系统化的梳理。
因此要想掌握Spark MLlib其实咱们不需要每天刻意抽出大段的时间去学习。不太谦虚地说专栏里的Spark MLlib模块已经足够系统化从范畴划分到适用场景从基础分类到典型案例解析。
通过这样的“分类指南”咱们已经掌握了Spark MLlib的主要脉络。接下来我们需要做的事情就是利用碎片化的时间钻进时间裂缝去学习每一个具体的函数或是模型算法为已有的知识体系添砖加瓦。
Spark MLlib模块如此Spark整体的学习也道理相通。关键在于上了一天班累得跟三孙子似的你是否还愿意钻进时间裂缝、利用一切空余时间以水滴石穿的毅力、持之以恒地完善你的知识体系。
实际上,像这种时间裂缝,并不仅仅是被动的通勤时间,在工作中,我们完全可以根据需要,主动地把时间切割为一个又一个裂缝。在每一个裂缝中,我们只专注于一个事件,不接受任何干扰。
比方说在每天工作的8小时里我们可以切割出来多个不连续的coding time在这些时间里我们不理会任何的即时消息只醉心于编写代码。当然从这8小时中我们也可以切割出来多个meeting裂缝这时我们暂且不管是不是还有个bug需要修复只专注于讨论、沟通以及如何说服对方。
总之时间裂缝的核心要义就是专注100%专注于一件事情。这其实有点像CPU的工作原理CPU的时钟周期是固定的每个时钟周期实际上只能处理一个任务。串行的工作方式看上去很笨但是一天下来你会发现这颗CPU实际上做了许多的事情。
拿我自己来说在过去的4个月里时间裂缝还帮我读完了一本书《清醒思考的艺术》、完成了一门极客时间课程[《技术管理实战36讲》]。在忙碌于产出的同时,还能有持续性的输入,我心里会觉得非常踏实,也会觉得很开心。
做一名坚定的技术内容生产者是我为自己设立的长期目标。而要想持续地输出高质量的内容持续学习必不可少。水柔弱而又刚强充满变化能适应万物的形状且从不向困难屈服。李小龙就曾经说过“Be water, my friend”。
让我们抓住每一个成长精进的契机,进入时间裂缝,持续学习,与君共勉。
最后,我还给你准备了一份毕业问卷,题目不多,两分钟左右就能填好,期待你能畅所欲言,谢谢。

View File

@ -0,0 +1,92 @@
阿里云2C2G3M 99元/年,老用户 也可以哦
001 「战略篇」访谈 DDD 和微服务是什么关系?
相信很多朋友对领域驱动设计会有这样或那样的困惑,比如领域驱动设计是什么?它在工作中有什么作用?为什么国内关于这方面的书籍少之又少?…… 为了解决这些疑惑,有幸邀请到专家张逸老师来聊聊领域驱动设计,下面是 GitChat 独家采访记录。
GitChat领域驱动设计Domain Driven DesignDDD自诞生以来已有十几年时间这门本已步入老年的方法学却因为微服务的兴起而焕发了第二春。您说过这可能要归功于 DDD 的“坚硬生长”,但不可否认微服务确实也是一个重要因素,能否请您解释一下领域驱动设计和微服务这种深层次的匹配关系?
张逸:领域驱动设计是由 Eric Evans 在一本《领域驱动设计》书中提出的,它是针对复杂系统设计的一套软件工程方法;而微服务是一种架构风格,一个大型复杂软件应用是由一个或多个微服务组成的,系统中的各个微服务可被独立部署,各个微服务之间是松耦合的,每个微服务仅关注于完成一件任务并很好地完成该任务。
两者之间更深入的关系,在我写的课程中已有详细讲解。主要体现在领域驱动设计中限界上下文与微服务之间的映射关系。假如限界上下文之间需要跨进程通信,并形成一种零共享架构,则每个限界上下文就成为了一个微服务。在微服务架构大行其道的当今,我们面临的一个棘手问题是:如何识别和设计微服务?领域驱动的战略设计恰好可以在一定程度上解决此问题。
GitChat如果说轻量化处理、自动部署以及容器技术的发展使得微服务的兴起成为必然那么是否可以说领域驱动设计今日的再续辉煌也是一种必然或者说 DDD 在其诞生之时过于超前)?您能否预测一下 DDD 未来可能会和什么样的新理念相结合?
张逸:好像领域驱动设计就从未真正“辉煌”过,所以谈不上再续辉煌,但确实是因为微服务引起了社区对它的重燃热情。推行领域驱动设计确乎有许多阻力,一方面要做到纯粹的领域驱动设计,许多团队成员的技能达不到;另一方面,似乎领域驱动设计带来的价值不经过时间的推移无法彰显其价值,这就缺乏足够的说服力让一家公司不遗余力地去推广领域驱动设计。微服务似乎给了我们一个推动领域驱动设计的理由!因为软件系统的微服务化已经成为了一种潮流,领域驱动设计又能够为微服务化保驾护航,还有什么理由不推行呢?
我个人认为,未来 DDD 的发展可能会出现以下趋势:
以函数式编程思想为基础的领域建模理念与事件驱动架构和响应式编程的结合,可能在低延迟高并发的项目中发挥作用。这种领域驱动设计思想已经比较成熟,但目前还没有看到太多成功的运用。
以 DDD 设计方法为基础的框架的出现,让微服务设计与领域建模变得更加容易,降低领域驱动设计的门槛。
GitChat能否尽可能地详细或举例说明您在阅读并审校《实现领域驱动设计》一书时所认识到的领域驱动设计的本质—— 一个开放的设计方法体系 ——是什么?
张逸在《实现领域驱动设计》一书中Vernon 不仅对整个领域驱动设计过程作了一番有益的梳理,还结合社区发展在书中引入了六边形架构和领域事件等概念,这为当时的我打开了一扇全新的窗户——原来领域驱动设计并不是一套死板的方法,而是一种设计思想、一种开放的设计方法体系,只要有利于领域驱动设计的实践,都可以引入其中。于是,在我的书中我才敢于大胆地引入用例、敏捷实践、整洁架构,以期为领域驱动设计提供补充。
Eric Evans 的《领域驱动设计》是以面向对象设计作为模型驱动设计的基础,但时下被频繁运用的函数式编程思想也给模型驱动设计带来了另一种视角。从开放的设计方法体系的角度讲,我们完全可以把更多的编程范式引入到领域驱动设计中。因为有了更多的选择,针对不同的业务场景就可以选择更适合的 DDD 实践,而不仅仅限于 Eric Evans 最初提出的范畴。
GitChat团队内外成员之间的协作与沟通一直以来都是个难题也是大家经常喜欢调侃的话题之一能否举例说明一下领域驱动设计是如何解决这一问题的
张逸:我觉得这个问题问反了。领域驱动设计解决不了这个问题,它只是重视这个问题;相反,我们应该说只有解决了团队内外成员之间的协作与沟通,才能更好地进行领域驱动设计。为此,我尝试用一些敏捷实践来解决这种协作问题。
GitChat您在学习和实践领域驱动设计的过程中是否有哪些有趣的故事可以和读者们分享
张逸:我在 ThoughtWorks 的时候,公司邀请《实现领域驱动设计》作者 Vaughn Vernon 到北京 Office 给我们做了一次 DDD 培训。借着这次亲炙大师教诲的机会我向他请教了一个一直缠绕在我心中困惑不解的问题“如何正确地识别限界上下文”结果他思考了一会儿严肃地回答了我“By experience” 我唯有无言以对。
GitChat有很多读者对您即将在课程中给出全真案例“EAS 系统”很感兴趣,能否简单介绍一下这个案例以及它在实际应用中的意义?
张逸EAS 系统是我之前做过的一个真实项目,之所以选择这个项目来作为这个专栏的全真案例,原因如下:
学习 DDD 必须理论联系实际。虽然在我写的课程内容中已经结合理论讲解提供了较多的实际案例,但这些零散的案例无法给读者提供一个整体的印象。
EAS 系统的业务知识门槛相对较低,不至于因为不熟悉领域知识而影响对 DDD 的学习。
EAS 系统具备一定的业务复杂度,既适合战略设计阶段,又适合战术阶段。
GitChat您提到这次的 DDD 系列专栏分为《战略篇》和《战术篇》两部分,这两个课程在内容设计上侧重有什么不同?很多读者关心《领域驱动战术设计实践》何时发布,可否透露一下?
张逸:这两部分对应于 DDD 的战略设计阶段与战术设计阶段,粗略地说,前者更偏向于架构,后者更偏向于设计与编码。事实上,就我个人的规划来说,计划还有第三部分,是围绕着函数式编程讲解与 DDD 有关的实践,包括 EDA、CQRS、Domain Event 等知识。
目前,《战略篇》还有最后几个章节没有完成。一旦完成后,就可以开始撰写《战术篇》内容了。当然,战术设计的相关内容已有部分初稿,我争取能够在 11 月发布这部分内容。
GitChat您觉得这门课的学员/读者应该是什么样的人?对于这些人,要想掌握领域驱动设计乃至在专业领域更上一层楼,您有哪些学习建议?
张逸:学习课程的学员/读者最好要有一定的软件设计能力,并对 DDD 学习抱有好奇心,希望能够将 DDD 学以致用。
学习建议:
积累领域知识,以提高沟通与协作能力;
以 Eric Evans 的《领域驱动设计》为主体,广泛涉猎与 DDD 相关的书籍与文章,并关注 DDD 社区的最新知识;
要善于总结,理清 DDD 中各个概念之间的区别与应用场景。
GitChat作为一位曾就职于中兴、惠普、中软、ThoughtWorks 等大型中外企业的架构师/技术总监/首席咨询师,在职业发展方面,您对您的读者们有哪些建议?
张逸:我之前在 ThoughtWorks 的同事郑晔(校长)给我提过一个建议,就是打造自己的技术标签。例如,现在 DDD 就成为了我其中的一个技术标签了。这个说法的内在含义,就是要寻找和定位自己的技术发展方向,然后往更深的方向钻研,最终成为这个方向的技术专家。因此,结合自己的能力特长、兴趣点以及技术发展趋势去规划自己的技术发展方向,才是技术人员最应该思考并践行的。

View File

@ -0,0 +1,100 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
002 「战略篇」开篇词:领域驱动设计,重焕青春的设计经典
专栏背景
领域驱动设计确实已不再青春,从 Eric Evans 出版的那本划时代的著作《领域驱动设计》至今,已有将近十五年的时间,在软件设计领域中,似乎可以称得上是步入老年时代了。可惜的是,对于这样一个在国外 IT 圈享有盛誉并行之有效的设计方法学,国内大多数的技术人员却并不了解,也未曾运用到项目实践中,真可以说是知音稀少。领域驱动设计似乎成了一门悄悄发展的隐学,它从来不曾大行其道,却依旧顽强地发挥着出人意料的价值。
直到行业内吹起微服务的热风,人们似乎才重新发现了领域驱动设计的价值,并不是微服务拯救了领域驱动设计,是因为领域驱动设计一直在坚硬的生长,然而看起来,确乎因为微服务,领域驱动设计才又焕发了青春。
我从 2006 年开始接触领域驱动设计,一开始我就发现了它的魅力并沉迷其间。从阅读 Eric Evans 的《领域驱动设计》入门,然后尝试在软件项目中运用它,也取得了一定成效。然而,我的学习与运用一直处于摸索之中,始终感觉不得其门而入,直到有机会拜读 Vaughn Vernon 出版的《实现领域驱动设计》一书,并负责该书的审校工作,我才触摸到了领域驱动从战略设计到战术设计的整体脉络,并了解其本质:领域驱动设计是一个开放的设计方法体系。
即使如此,许多困惑与谜题仍然等待我去发现线索和答案。设计总是如此,虽然前人已经总结了许多原则与方法,却不能像数学计算那样,按照公式与公理进行推导就一定能得到准确无误的结果。设计没有唯一的真相。
即使如此,如果我们能够走在迈向唯一真相的正确道路上,那么每前进一步,就会离这个理想的唯一真相更近一步,这正是我推出这门课的初衷。也并不是说我贴近了唯一真相,更不是说我已经走在了正确道路上,但我可以自信地说,对于领域驱动设计,我走在了大多数开发人员的前面,在我发现了更多新奇风景的同时,亦走过太多荒芜的分岔小径,经历过太多坎坷与陷阱。我尝试着解答领域驱动设计的诸多谜题,期望能从我的思考与实践中发现正确道路的蛛丝马迹。我写的这门专栏正是我跌跌撞撞走过一路的风景拍摄与路径引导,就好似你要去银河系旅游,最好能有一本《银河系漫游指南》在手一样,不至于迷失在浩瀚的星空之中,我期待这门专栏能给你带来这样的指导。
专栏框架
本专栏是我计划撰写的领域驱动设计实践系列的第一部分内容(第二部分内容是领域驱动战术设计实践,后面陆续更新),其全面覆盖了领域建模分析与架构设计的战略设计过程,从剖析软件复杂度的根源开始,引入了领域场景分析与敏捷项目实践,帮助需求分析人员与软件设计人员分析软件系统的问题域,提炼真实表达的领域知识,最终建立系统的统一语言。同时,本专栏将主流架构设计思想、微服务架构设计原则与领域驱动设计中属于战略设计层面的限界上下文、上下文映射、分层架构结合起来,完成从需求到架构设计再到构建代码模型的架构全过程。
本专栏分为五部分,共计 34 篇。
开篇词:领域驱动设计,重焕青春的设计经典
第一部分(第 3~7 篇):软件复杂度
领域驱动设计的目的是应对软件复杂度。本部分内容以简练的笔触勾勒出了领域驱动设计的全貌,然后深入剖析了软件复杂度的本质,总结了控制软件复杂度的原则,最终给出了领域驱动设计应对软件复杂度的基本思想与方法。
第二部分(第 8~12 篇):领域知识
领域驱动设计的核心是“领域”,也是进行软件设计的根本驱动力。因此,团队在进行领域驱动设计时,尤其需要重视团队内外成员之间的协作与沟通。本部分内容引入了敏捷开发思想中的诸多实践,并以领域场景分析为主线讲解了如何提炼领域知识的方法。
第三部分(第 13~22 篇):限界上下文
限界上下文是领域驱动设计最重要的设计要素,我们需要充分理解限界上下文的本质与价值,突出限界上下文对业务、团队与技术的“控制”能力。
提出了从业务边界、工作边界到应用边界分阶段分步骤迭代地识别限界上下文的过程方法,使得领域驱动设计的新手能够有一个可以遵循的过程来帮助识别限界上下文。
剖析上下文映射,确定限界上下文之间的协作关系,进一步帮助我们合理地设计限界上下文。
第四部分(第 23~30 篇):架构与代码模型
作为一个开放的设计方法体系,本部分引入了分层架构、整洁架构、六边形架构与微服务架构等模式,全面剖析了领域驱动设计的架构思想与原则。
结合限界上下文,并针对限界上下文的不同定义,对领域驱动的架构设计进行了深度探索,给出了满足整洁架构思想的代码模型。
第五部分(第 31~36 篇EAS 系统的战略设计实践
给出一个全真案例——EAS 系统,运用各篇介绍的设计原则、模式与方法对该系统进行全方位的战略设计,并给出最终的设计方案。
本专栏并非是对 Eric Evans《领域驱动设计》的萧规曹随而是吸纳了领域驱动设计社区的各位专家大师提出的先进知识并结合我多年来运用领域驱动设计收获的项目经验同时还总结了自己在领域驱动设计咨询与培训中对各种困惑与问题的思考与解答。本专栏内容既遵循了领域驱动设计的根本思想又有自己的独到见解既给出了权威的领域驱动知识阐释又解答了在实践领域驱动设计中最让人困惑的问题。
为什么要学习领域驱动设计
如果你已经能设计出美丽优良的软件架构,如果你只希望脚踏实地做一名高效编码的程序员,如果你是一位注重用户体验的前端设计人员,如果你负责的软件系统并不复杂,那么,你确实不需要学习领域驱动设计!
领域驱动设计当然并非“银弹”,自然也不是解决所有疑难杂症的“灵丹妙药”,请事先降低对领域驱动设计的不合现实的期望。我以中肯地态度总结了领域驱动设计可能会给你带来的收获:
领域驱动设计是一套完整而系统的设计方法,它能带给你从战略设计到战术设计的规范过程,使得你的设计思路能够更加清晰,设计过程更加规范。
领域驱动设计尤其善于处理与领域相关的高复杂度业务的产品研发,通过它可以为你的产品建立一个核心而稳定的领域模型内核,有利于领域知识的传递与传承。
领域驱动设计强调团队与领域专家的合作,能够帮助团队建立一个沟通良好的团队组织,构建一致的架构体系。
领域驱动设计强调对架构与模型的精心打磨,尤其善于处理系统架构的演进设计。
领域驱动设计的思想、原则与模式有助于提高团队成员的面向对象设计能力与架构设计能力。
领域驱动设计与微服务架构天生匹配,无论是在新项目中设计微服务架构,还是将系统从单体架构演进到微服务设计,都可以遵循领域驱动设计的架构原则。
专栏寄语
没有谁能够做到领域驱动设计的一蹴而就,一门专栏也不可能穷尽领域驱动设计的方方面面,从知识的学习到知识的掌握,进而达到能力的提升,需要一个漫长的过程。所谓“理论联系实际”虽然是一句耳熟能详的老话,但其中蕴含了颠扑不破的真理。我在进行领域驱动设计培训时,总会有学员希望我能给出数学公式般的设计准则或规范,似乎软件设计就像拼积木一般,只要遵照图示中给出的拼搭过程,不经思考就能拼出期待的模型。——这是不切实际的幻想。
要掌握领域驱动设计,就不要被它给出的概念所迷惑,而要去思索这些概念背后蕴含的原理,多问一些为什么。同时,要学会运用设计原则去解决问题,而非所谓的“设计规范”。例如:
思考限界上下文边界的划分,实际上还是“高内聚、低耦合”原则的体现,只是我们需要考虑什么内容才是高内聚的,如何抽象才能做到低耦合?
是否需要提取单独的限界上下文?是为了考虑职责的重用,还是为了它能够独立进化以应对未来的变化?
在分层架构中,各层之间该如何协作?如果出现了依赖,该如何解耦?仍然需要从重用与变化的角度去思考设计决策。
为什么同样遵循领域驱动设计,不同的系统会设计出不同的架构?这是因为不同的场景对架构质量的要求并不一样,我们要学会对架构的关注点做优先级排列,从而得出不同的架构决策。
我强烈建议读者诸君要学会对设计的本质思考,不要只限于对设计概念的掌握,而要追求对设计原则与方法的融汇贯通。只有如此,才能针对不同的业务场景灵活地运用领域驱动设计,而非像一个牵线木偶般遵照着僵硬的过程进行死板地设计。
分享交流
我们为本专栏付费读者创建了微信交流群,以方便更有针对性地讨论专栏相关问题。入群方式请到第 5 篇末尾添加小编的微信号。
阅读文章过程中有任何疑问随时可以跟其他小伙伴讨论,或者直接向作者提问(作者看到后抽空回复)。你的分享不仅帮助他人,更会提升自己。

View File

@ -0,0 +1,79 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
003 领域驱动设计概览
领域驱动设计Domain Driven DesignDDD是由 Eric Evans 最早提出的综合软件系统分析和设计的面向对象建模方法,如今已经发展成为了一种针对大型复杂系统的领域建模与分析方法。它完全改变了传统软件开发工程师针对数据库进行的建模方法,从而将要解决的业务概念和业务规则转换为软件系统中的类型以及类型的属性与行为,通过合理运用面向对象的封装、继承和多态等设计要素,降低或隐藏整个系统的业务复杂性,并使得系统具有更好的扩展性,应对纷繁多变的现实业务问题。
领域驱动设计的开放性
领域驱动设计是一种方法论Methodology根据维基百科的定义方法论是一套运用到某个研究领域的系统与理论分析方法。领域驱动设计就是针对软件开发领域提出的一套系统与理论分析方法。Eric Evans 在创造性地提出领域驱动设计时,实则是针对当时项目中聚焦在以数据以及数据样式为核心的系统建模方法的批判。面向数据的建模方法是关系数据库理论的延续,关注的是数据表以及数据表之间关系的设计。这是典型的面向技术实现的建模方法,面对日渐复杂的业务逻辑,这种设计方法欠缺灵活性与可扩展性,也无法更好地利用面向对象设计思想及设计模式,建立可重用的、可扩展的代码单元。领域驱动设计的提出,是设计观念的转变,蕴含了全新的设计思想、设计原则与设计过程。
由于领域驱动设计是一套方法论它建立了以领域为核心驱动力的设计体系因而具有一定的开放性。在这个体系中你可以使用不限于领域驱动设计提出的任何一种方法来解决这些问题。例如可以使用用例Use Case、测试驱动开发TDD、用户故事User Story来帮助我们对领域建立模型可以引入整洁架构思想及六边形架构以帮助我们建立一个层次分明、结构清晰的系统架构还可以引入函数式编程思想利用纯函数与抽象代数结构的不变性以及函数的组合性来表达领域模型。这些实践方法与模型已经超越了 Eric Evans 最初提出的领域驱动设计范畴,但在体系上却是一脉相承的。这也是为什么在领域驱动设计社区,能够不断诞生新的概念诸如 CQRS 模式、事件溯源Event Sourcing模式与事件风暴Event Storming领域驱动设计也以开放的心态拥抱微服务Micro Service甚至能够将它的设计思想与原则运用到微服务架构设计中。
领域驱动设计过程
领域驱动设计当然不是架构方法,也并非设计模式。准确地说,它其实是“一种思维方式,也是一组优先任务,它旨在加速那些必须处理复杂领域的软件项目的开发”。领域驱动设计贯穿了整个软件开发的生命周期,包括对需求的分析、建模、架构、设计,甚至最终的编码实现,乃至对编码的测试与重构。
领域驱动设计强调领域模型的重要性并通过模型驱动设计来保障领域模型与程序设计的一致。从业务需求中提炼出统一语言Ubiquitous Language再基于统一语言建立领域模型这个领域模型会指导着程序设计以及编码实现最后又通过重构来发现隐式概念并运用设计模式改进设计与开发质量。这个过程如下图所示
这个过程是一个覆盖软件全生命周期的设计闭环,每个环节的输出都可以作为下一个环节的输入,而在其中扮演重要指导作用的则是“领域模型”。这个设计闭环是一个螺旋式的迭代设计过程,领域模型会在这个迭代过程中逐渐演进,在保证模型完整性与正确性的同时,具有新鲜的活力,使得领域模型能够始终如一的贯穿领域驱动设计过程、阐释着领域逻辑、指导着程序设计、验证着编码质量。
如果仔细审视这个设计闭环,会发现在针对问题域和业务期望提炼统一语言,并通过统一语言进行领域建模时,可能会面临高复杂度的挑战。这是因为对于一个复杂的软件系统而言,我们要处理的问题域实在太庞大了。在为问题域寻求解决方案时,需要从宏观层次划分不同业务关注点的子领域,然后再深入到子领域中从微观层次对领域进行建模。宏观层次是战略的层面,微观层次是战术的层面,只有将战略设计与战术设计结合起来,才是完整的领域驱动设计。
战略设计阶段
领域驱动设计的战略设计阶段是从下面两个方面来考量的:
问题域方面针对问题域引入限界上下文Bounded Context和上下文映射Context Map对问题域进行合理的分解识别出核心领域Core Domain与子领域SubDomain并确定领域的边界以及它们之间的关系维持模型的完整性。
架构方面通过分层架构来隔离关注点尤其是将领域实现独立出来能够更利于领域模型的单一性与稳定性引入六边形架构可以清晰地表达领域与技术基础设施的边界CQRS 模式则分离了查询场景和命令场景,针对不同场景选择使用同步或异步操作,来提高架构的低延迟性与高并发能力。
Eric Evans 提出战略设计的初衷是要保持模型的完整性。限界上下文的边界可以保护上下文内部和其他上下文之间的领域概念互不冲突。然而,如果我们将领域驱动设计的战略设计模式引入到架构过程中,就会发现限界上下文不仅限于对领域模型的控制,而在于分离关注点之后,使得整个上下文可以成为独立部署的设计单元,这就是“微服务”的概念,上下文映射的诸多模式则对应了微服务之间的协作。因此在战略设计阶段,微服务扩展了领域驱动设计的内容,反过来领域驱动设计又能够保证良好的微服务设计。
一旦确立了限界上下文的边界,尤其是作为物理边界,则分层架构就不再针对整个软件系统,而仅仅针对粒度更小的限界上下文。此时,限界上下文定义了技术实现的边界,对当前上下文的领域与技术实现进行了封装,我们只需要关心对外暴露的接口与集成方式,形成了在服务层次的设计单元重用。
边界给了实现限界上下文内部的最大自由度,这也是战略设计在分治上起到的效用,我们可以在不同的限界上下文选择不同的架构模式。例如,针对订单的查询与处理,选择 CQRS 模式来分别处理同步与异步场景还可以针对核心领域与子领域重要性的不同分别选择领域模型Domain Model和事务脚本Transaction Script模式灵活地平衡开发成本与开发质量。在宏观层面面对整个软件系统我们可以采用前后端分离与基于 REST 的微服务架构,保证系统具有一致的架构风格。
战术设计阶段
整个软件系统被分解为多个限界上下文(或领域)后,就可以分而治之,对每个限界上下文进行战术设计。领域驱动设计并不牵涉到技术层面的实现细节,在战术层面,它主要应对的是领域的复杂性。领域驱动设计用以表示模型的主要要素包括:
值对象Value Object
实体Entity
领域服务Domain Service
领域事件Domain Event
资源库Repository
工厂Factory
聚合Aggregate
应用服务Application Service
Eric Evans 通过下图勾勒出了战术设计诸要素之间的关系:
领域驱动设计围绕着领域模型进行设计通过分层架构Layered Architecture将领域独立出来。表示领域模型的对象包括实体、值对象和领域服务领域逻辑都应该封装在这些对象中。这一严格的设计原则可以避免业务逻辑渗透到领域层之外导致技术实现与业务逻辑的混淆。在领域驱动设计的演进中又引入了领域事件来丰富领域模型。
聚合是一种边界它可以封装一到多个实体与值对象并维持该边界范围之内的业务完整性。在聚合中至少包含一个实体且只有实体才能作为聚合根Aggregate Root。注意在领域驱动设计中没有任何一个类是单独的聚合因为聚合代表的是边界概念而非领域概念。在极端情况下一个聚合可能有且只有一个实体。
工厂和资源库都是对领域对象生命周期的管理。前者负责领域对象的创建,往往用于封装复杂或者可能变化的创建逻辑;后者则负责从存放资源的位置(数据库、内存或者其他 Web 资源)获取、添加、删除或者修改领域对象。领域模型中的资源库不应该暴露访问领域对象的技术实现细节。
演进的领域驱动设计过程
战略设计会控制和分解战术设计的边界与粒度,战术设计则以实证角度验证领域模型的有效性、完整性与一致性,进而以演进的方式对之前的战略设计阶段进行迭代,从而形成一种螺旋式上升的迭代设计过程,如下图所示:
面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,以获得清晰的问题域。通过对问题域进行分析和建模,识别限界上下文,利用它划分相对独立的领域,再通过上下文映射建立它们之间的关系,辅以分层架构与六边形架构划分系统的逻辑边界与物理边界,界定领域与技术之间的界限。之后,进入战术设计阶段,深入到限界上下文内对领域进行建模,并以领域模型指导程序设计与编码实现。若在实现过程中,发现领域模型存在重复、错位或缺失时,再进而对已有模型进行重构,甚至重新划分限界上下文。
两个不同阶段的设计目标是保持一致的,它们是一个连贯的过程,彼此之间又相互指导与规范,并最终保证一个有效的领域模型和一个富有表达力的实现同时演进。

View File

@ -0,0 +1,106 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
004 深入分析软件的复杂度
软件复杂度的成因
Eric Evans 的经典著作《领域驱动设计》的副标题为“软件核心复杂性应对之道”,这说明了 Eric 对领域驱动设计的定位就是应对软件开发的复杂度。Eric 甚至认为:“领域驱动设计只有应用在大型项目上才能产生最大的收益”。他通过 Smart UI 反模式逆向地说明了在软件设计与开发过程中如果出现了如下问题,就应该考虑运用领域驱动设计:
没有对行为的重用,也没有对业务问题的抽象,每当操作用到业务规则时,都要重复这些业务规则。
快速的原型建立和迭代很快会达到其极限,因为抽象的缺乏限制了重构的选择。
复杂的功能很快会让你无所适从,所以程序的扩展只能是增加简单的应用模块,没有很好的办法来实现更丰富的功能。
因此,选择领域驱动设计,就是要与软件系统的复杂作一番殊死拼搏,以降低软件复杂度为己任。那么,什么才是复杂呢?
什么是复杂?
即使是研究复杂系统的专家,如《复杂》一书的作者 Melanie Mitchell都认为复杂没有一个明确得到公认的定义。不过Melanie Mitchell 在接受 Ubiquity 杂志专访时,还是“勉为其难”地给出了一个通俗的复杂系统定义:由大量相互作用的部分组成的系统,与整个系统比起来,这些组成部分相对简单,没有中央控制,组成部分之间也没有全局性的通讯,并且组成部分的相互作用导致了复杂行为。
这个定义庶几可以表达软件复杂度的特征。定义中的组成部分对于软件系统来说,就是我所谓的“设计单元”,基于粒度的不同可以是函数、对象、模块、组件和服务。这些设计单元相对简单,然而彼此之间的相互作用却导致了软件系统的复杂行为。
Jurgen Appelo 从理解力与预测能力两个维度分析了复杂系统理论,这两个维度又各自分为不同的复杂层次,其中,理解力维度分为 Simple 与 Comlicated 两个层次,预测能力维度则分为 Ordered、Complex 与 Chaotic 三个层次,如下图所示:
参考复杂的含义Complicated 与 Simple简单相对意指非常难以理解而 Complex 则介于 Ordered有序的与 Chaotic混沌的之间认为在某种程度上可以预测但会有很多出乎意料的事情发生。显然对于大多数软件系统而言系统的功能都是难以理解的在对未来需求变化的把控上虽然我们可以遵循一些设计原则来应对可能的变化但未来的不可预测性使得软件系统的演进仍然存在不可预测的风险。因此软件系统的所谓“复杂”其实覆盖了 Complicated 与 Complex 两个方面。要理解软件复杂度的成因,就应该结合理解力与预测能力这两个因素来帮助我们思考。
理解力
在软件系统中,是什么阻碍了开发人员对它的理解?想象团队招入一位新人,就像一位游客来到了一座陌生的城市,他是否会迷失在阡陌交错的城市交通体系中,不辨方向?倘若这座城市实则是乡野郊外的一座村落,不过只有房屋数间,一条街道连通城市的两头,还会疑生出迷失之感吗?
因而,影响理解力的第一要素是规模。
规模
软件的需求决定了系统的规模。当需求呈现线性增长的趋势时,为了实现这些功能,软件规模也会以近似的速度增长。由于需求不可能做到完全独立,导致出现相互影响相互依赖的关系,修改一处就会牵一发而动全身。就好似城市的一条道路因为施工需要临时关闭,此路不通,通行的车辆只能改道绕行,这又导致了其他原本已经饱和的道路,因为涌入更多车辆,超出道路的负载从而变得更加拥堵,这种拥堵现象又会顺势向这些道路的其他分叉道路蔓延,形成一种辐射效应的拥堵现象。
软件开发的拥堵现象或许更严重:
函数存在副作用,调用时可能对函数的结果作了隐含的假设;
类的职责繁多,不敢轻易修改,因为不知这种变化会影响到哪些模块;
热点代码被频繁变更,职责被包裹了一层又一层,没有清晰的边界;
在系统某个角落,隐藏着伺机而动的 bug当诱发条件具备时则会让整条调用链瘫痪
不同的业务场景包含了不同的例外场景,每种例外场景的处理方式都各不相同;
同步处理与异步处理代码纠缠在一起,不可预知程序执行的顺序。
当需求增多时软件系统的规模也会增大且这种增长趋势并非线性增长会更加陡峭。倘若需求还产生了事先未曾预料到的变化我们又没有足够的风险应对措施在时间紧迫的情况下难免会对设计做出妥协头疼医头、脚疼医脚在系统的各个地方打上补丁从而欠下技术债Technical Debt。当技术债务越欠越多累计到某个临界点时就会由量变引起质变整个软件系统的复杂度达到巅峰步入衰亡的老年期成为“可怕”的遗留系统。正如饲养场的“奶牛规则”奶牛逐渐衰老最终无奶可挤然而与此同时饲养成本却在上升。
结构
不知大家是否去过迷宫?相似而回旋繁复的结构使得本来封闭狭小的空间被魔法般地扩展为一个无限的空间,变得无穷大,仿佛这空间被安置了一个循环,倘若没有找到正确的退出条件,循环就会无休无止,永远无法退出。许多规模较小却格外复杂的软件系统,就好似这样的一座迷宫。
此时,结构成了决定系统复杂度的关键因素。
结构之所以变得复杂在多数情况下还是因为系统的质量属性决定的。例如我们需要满足高性能、高并发的需求就需要考虑在系统中引入缓存、并行处理、CDN、异步消息以及支持分区的可伸缩结构。倘若我们需要支持对海量数据的高效分析就得考虑这些海量数据该如何分布存储并如何有效地利用各个节点的内存与 CPU 资源执行运算。
从系统结构的视角看,单体架构一定比微服务架构更简单,更便于掌控,正如单细胞生物比人体的生理结构要简单数百倍;那么,为何还有这么多软件组织开始清算自己的软件资产,花费大量人力物力对现有的单体架构进行重构,走向微服务化?究其主因,不还是系统的质量属性在作祟吗?
纵观软件设计的历史,不是分久必合、合久必分,而是不断拆分、继续拆分、持续拆分的微型化过程。分解的软件元素不可能单兵作战,怎么协同、怎么通信,就成为了系统分解后面临的主要问题。如果没有控制好,这些问题固有的复杂度甚至会在某些场景下超过因为分解给我们带来的收益。
无论是优雅的设计,还是拙劣的设计,都可能因为某种设计权衡而导致系统结构变得复杂。唯一的区别在于前者是主动地控制结构的复杂度,而后者带来的复杂度是偶发的,是错误的滋生,是一种技术债,它可能会随着系统规模的增大而导致一种无序设计。
在 Pete Goodliffe 讲述的《两个系统的故事:现代软件神话》中详细地罗列了无序设计系统的几种警告信号:
代码没有显而易见的进入系统中的路径;
不存在一致性、不存在风格、也没有统一的概念能够将不同的部分组织在一起;
系统中的控制流让人觉得不舒服,无法预测;
系统中有太多的“坏味道”,整个代码库散发着腐烂的气味儿,是在大热天里散发着刺激气体的一个垃圾堆;
数据很少放在使用它的地方,经常引入额外的巴罗克式缓存层,目的是试图让数据停留在更方便的地方。
我们看一个无序设计的软件系统,就好像隔着一层半透明的玻璃观察事物一般,系统中的软件元素都变得模糊不清,充斥着各种技术债。细节层面,代码污浊不堪,违背了“高内聚、松耦合”的设计原则,导致许多代码要么放错了位置,要么出现重复的代码块;架构层面,缺乏清晰的边界,各种通信与调用依赖纠缠在一起,同一问题域的解决方案各式各样,让人眼花缭乱,仿佛进入了没有规则的无序社会。
预测能力
当我们掌握了事物发展的客观规律时,我们就具有了一定的对未来的预测能力。例如,我们洞察了万有引力的本质,就可以对我们能够观察到的宇宙天体建立模型,较准确地推测出各个天体在未来一段时间的运行轨迹。然而,宇宙空间变化莫测,或许因为一个星球的死亡产生黑洞的吸噬能力,就可能导致那一片星域产生剧烈的动荡,这种动荡会传递到更远的星空,从而干扰了我们的预测。坦白说,我们现在连自己居住的地球天气都不能做一个准确的预测呢。之所以如此,正是因为未知的变化的产生。
变化
未来总会出现不可预测的变化,这种不可预测性带来的复杂度,使得我们产生畏惧,因为我们不知道何时会发生变化,变化的方向又会走向哪里,这就导致心理滋生一种仿若失重一般的感觉。变化让事物失去控制,受到事物牵扯的我们会感到惶恐不安。
在设计软件系统时,变化让我们患得患失,不知道如何把握系统设计的度。若拒绝对变化做出理智的预测,系统的设计会变得僵化,一旦变化发生,修改的成本会非常的大;若过于看重变化产生的影响,渴望涵盖一切变化的可能,一旦预期的变化不曾发生,我们之前为变化付出的成本就再也补偿不回来了。这就是所谓的“过度设计”。
从需求的角度讲变化可能来自业务需求也可能来自质量属性。以对系统架构的影响而言尤以后者为甚因为它可能牵涉到整个基础架构的变更。George Fairbanks在《恰如其分的软件架构》一书中介绍了邮件托管服务公司 RackSpace 的日志架构变迁,业务功能没有任何变化,却因为邮件数量的持续增长,为满足性能需求,架构经历了三个完全不同系统的变迁:从最初的本地日志文件,到中央数据库,再到基于 HDFS 的分布式存储,整个系统几乎发生了颠覆性的变化。这并非 RackSpace 的设计师欠缺设计能力,而是在公司草创之初,他们没有能够高瞻远瞩地预见到客户数量的增长,导致日志数据增多,以至于超出了已有系统支持的能力范围。俗话说:“事后诸葛亮”,当我们在对一个软件系统的架构设计进行复盘时,总会发现许多设计决策是如此的愚昧。殊不知这并非愚昧,而是在设计当初,我们手中掌握的筹码不足以让自己赢下这场面对未来的战争罢了。
这就是变化之殇!
如果将软件系统中我们自己开发的部分都划归为需求的范畴,那么还有一种变化,则是因为我们依赖的第三方库、框架或平台、甚至语言版本的变化带来的连锁反应。例如,作为 Java 开发人员,一定更垂涎于 Lambda 表达式的简洁与抽象,又或者 Jigsaw 提供的模块定义能力,然而现实是我们看到多数的企业软件系统依旧在 Java 6 或者 Java 7 中裹足不前。
这还算是幸运的例子,因为我们尽可以满足这种故步自封,由于情况并没有到必须变化的境地。当我们依赖的第三方有让我们不得不改变的理由时,难道我们还能拒绝变化吗?
许多软件在版本变迁过程中都尽量考虑到 API 变化对调用者带来的影响,因而尽可能保持版本向后兼容。我亲自参与过系统从 Spring 2.0 到 4.0 的升级Spark 从 1.3.1 到 1.5 再到 1.6 的升级,感谢这些框架或平台设计人员对兼容性的体贴照顾,使得我们的升级成本能够被降到最低;但是在升级之后,倘若没有对系统做全方位的回归测试,我们的内心始终是惴惴不安的。
对第三方的依赖看似简单,殊不知我们所依赖的库、平台或者框架又可能依赖了若干对于它们而言又份属第三方的更多库、平台和框架。每回初次构建软件系统时,我都为漫长等待的依赖下载过程而感觉烦躁不安。多种版本共存时可能带来的所谓依赖地狱,只要亲身经历过,就没有不感到不寒而栗的。倘若你运气欠佳,可能还会有各种古怪问题接踵而来,让你应接不暇、疲于奔命。
如果变化是不可预测的,那么软件系统也会变得不可预测。一方面我们要尽可能地控制变化,至少要将变化产生的影响限制在较小的空间范围内;另一方面又要保证系统不会因为满足可扩展性而变得更加复杂,最后背上过度设计的坏名声。软件设计者们就像走在高空钢缆的技巧挑战者,惊险地调整重心以维持行动的平衡。故而,变化之难,在于如何平衡。

View File

@ -0,0 +1,54 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
005 控制软件复杂度的原则
虽然说认识到软件系统的复杂本性,并不足以让我们应对其复杂,并寻找到简化系统的解决之道;然而,如果我们连导致软件复杂度的本源都茫然不知,又怎么谈得上控制复杂呢?既然我们认为导致软件系统变得复杂的成因是规模、结构与变化三要素,则控制复杂度的原则就需要对它们进行各个击破。
分而治之、控制规模
针对规模带来的复杂度,我们应注意克制做大、做全的贪婪野心,尽力保证系统的小规模。简单说来,就是分而治之的思想,遵循小即是美的设计美学。
丹尼斯·里奇Dennis MacAlistair Ritchie从大型项目 Multics 的失败中总结出 KISSKeep it Simple Stupid原则基于此原则他将 Unix 设计为由许多小程序组成的整体系统,每个小程序只能完成一个功能,任何复杂的操作都必须分解成一些基本步骤,由这些小程序逐一完成,再组合起来得到最终结果。从表面上看,运行一连串小程序很低效,但是事实证明,由于小程序之间可以像积木一样自由组合,所以非常灵活,能够轻易完成大量意想不到的任务。而且,计算机硬件的升级速度非常快,所以性能也不是一个问题;另一方面,当把大程序分解成单一目的的小程序,开发会变得很容易。
Unix 的这种设计哲学被 Doug McIlroy、Elliot Pinson 和 Berk Tague 总结为以下两条:
Make each program do one thing well. To do a new job, build a fresh rather than complicate old programs by adding new “features.”
Expect the output of every program to become the input to another, as yet unknown, program.
这两条原则是相辅相成的。第一条原则要求一个程序只做一件事情符合“单一职责原则”在应对新需求时不会直接去修改一个复杂的旧系统而是通过添加新特性然后对这些特性进行组合。要满足小程序之间的自由组合就需要满足第二条原则即每个程序的输入和输出都是统一的因而形成一个统一接口Uniform Interface以支持程序之间的自由组合Composability。利用统一接口既能够解耦每个程序又能够组合这些程序还提高了这些小程序的重用性这种“统一接口”其实就是架构一致性的体现。
保持结构的清晰与一致
所有设计质量高的软件系统都有相同的特征,就是拥有清晰直观且易于理解的结构。
Robert Martin 分析了这么多年诸多设计大师提出的各种系统架构风格与模式,包括 Alistair Cockburn 提出的六边形架构Hexagonal ArchitectureJeffrey Palermo 提出的洋葱架构Onion Architecture)James Coplien 与 Trygve Reenskaug 提出的 DCI 架构Ivar Jacobson 提出的 BCE 设计方法。结果,他认为这些方法的共同特征都遵循了“关注点分离”架构原则,由此提出了整洁架构的思想。
整洁架构提出了一个可测试的模型,无需依赖于任何基础设施就可以对它进行测试,只需通过边界对象发送和接收对应的数据结构即可。它们都遵循稳定依赖原则,不对变化或易于变化的事物形成依赖。整洁架构模型让外部易变的部分依赖于更加稳定的领域模型,从而保证了核心的领域模型不会受到外部的影响。典型的整洁架构如下图所示:
整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更为清晰,以减少不必要的耦合。要做到这一点,则需要合理地进行职责分配,良好的封装与抽象,并在约束的指导下为架构建立一致的风格,这是许多良好系统的设计特征。
拥抱变化
变化对软件系统带来的影响可以说是无解,然而我们不能因此而消极颓废,套用 Kent Beck 的话来说,我们必须“拥抱变化”。除了在开发过程中,我们应尽可能做到敏捷与快速迭代,以此来抵消变化带来的影响;在架构设计层面,我们还可以分析哪些架构质量属性与变化有关,这些质量属性包括:
可进化性Evolvability
可扩展性Extensibility
可定制性Customizability
要保证系统的可进化性,可以划分设计单元的边界,以确定每个设计单元应该履行的职责以及需要与其他设计单元协作的接口。这些设计单元具有不同的设计粒度,包括函数、对象、模块、组件及服务。由于每个设计单元都有自己的边界,边界内的实现细节不会影响到外部的其他设计单元,我们就可以非常容易地替换单元内部的实现细节,保证了它们的可进化性。
要满足系统的可扩展性,首先要学会识别软件系统中的变化点(热点),常见的变化点包括业务规则、算法策略、外部服务、硬件支持、命令请求、协议标准、数据格式、业务流程、系统配置、界面表现等。处理这些变化点的核心就是“封装”,通过隐藏细节、引入间接等方式来隔离变化、降低耦合。一些常见的架构风格,如基于事件的集成、管道—过滤器等的引入,都可以在一定程度上提高系统可扩展性。
可定制性意味着可以提供特别的功能与服务。Fielding 在《架构风格与基于网络的软件架构设计》提到:“支持可定制性的风格也可能会提高简单性和可扩展性”。在 SaaS 风格的系统架构中我们常常通过引入元数据Metadata来支持系统的可定制。插件模式也是满足可定制性的常见做法它通过提供统一的插件接口使得用户可以在系统之外按照指定接口编写插件来扩展定制化的功能。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
006 领域驱动设计对软件复杂度的应对(上)
不管是因为规模与结构制造的理解力障碍还是因为变化带来的预测能力问题最终的决定因素还是因为需求。Eric Evans 认为“很多应用程序最主要的复杂性并不在技术上,而是来自领域本身、用户的活动或业务”。因而,领域驱动设计关注的焦点在于领域和领域逻辑,因为软件系统的本质其实是给客户(用户)提供具有业务价值的领域功能。
需求引起的软件复杂度
需求分为业务需求与质量属性需求,因而需求引起的复杂度可以分为两个方面:技术复杂度与业务复杂度。
技术复杂度来自需求的质量属性,诸如安全、高性能、高并发、高可用性等需求,为软件设计带来了极大的挑战,让人痛苦的是这些因素彼此之间可能又互相矛盾、互相影响。例如,系统安全性要求对访问进行控制,无论是增加防火墙,还是对传递的消息进行加密,又或者对访问请求进行认证和授权等,都需要为整个系统架构添加额外的间接层,这不可避免会对访问的低延迟产生影响,拖慢了系统的整体性能。又例如,为了满足系统的高并发访问,我们需要对应用服务进行物理分解,通过横向增加更多的机器来分散访问负载;同时,还可以将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理。这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。
业务复杂度对应了客户的业务需求,因而这种复杂度往往会随着需求规模的增大而增加。由于需求不可能做到完全独立,一旦规模扩大到一定程度,不仅产生了功能数量的增加,还会因为功能互相之间的依赖与影响使得这种复杂度产生叠加,进而影响到整个系统的质量属性,比如系统的可维护性与可扩展性。在考虑系统的业务需求时,还会因为沟通不畅、客户需求不清晰等多种局外因素而带来的需求变更和修改。如果不能很好地控制这种变更,则可能会因为多次修改而导致业务逻辑纠缠不清,系统可能开始慢慢腐烂而变得不可维护,最终形成一种如 Brian Foote 和 Joseph Yoder 所说的“大泥球”系统。
以电商系统的促销规则为例。针对不同类型的顾客与产品,商家会提供不同的促销力度;促销的形式多种多样,包括赠送积分、红包、优惠券、礼品;促销的周期需要支持定制,既可以是特定的日期,如双十一促销,也可以是节假日的固定促销模式。如果我们在设计时没有充分考虑促销规则的复杂度,并处理好促销规则与商品、顾客、卖家与支付乃至于物流、仓储之间的关系,开发过程则会变得踉踉跄跄、举步维艰。
技术复杂度与业务复杂度并非完全独立,二者混合在一起产生的化合作用更让系统的复杂度变得不可预期,难以掌控。同时,技术的变化维度与业务的变化维度并不相同,产生变化的原因也不一致,倘若未能很好地界定二者之间的关系,系统架构缺乏清晰边界,会变得难以梳理。复杂度一旦增加,团队规模也将随之扩大,再揉以严峻的交付周期、人员流动等诸多因素,就好似将各种不稳定的易燃易爆气体混合在一个不可逃逸的密闭容器中一般,随时都可能爆炸:
随着业务需求的增加与变化,以及对质量属性的高标准要求,自然也引起了软件系统规模的增大与结构的繁杂,至于变化,则是软件开发绕不开的话题。因此,当我们面对一个相对复杂的软件系统时,通常面临的问题在于:
问题域过于庞大而复杂,使得从问题域中寻求解决方案的挑战增加,该问题与软件系统的规模有关。
开发人员将业务逻辑的复杂度与技术实现的复杂度混淆在一起,该问题与软件系统的结构有关。
随着需求的增长和变化,无法控制业务复杂度和技术复杂度,该问题与软件系统的变化有关。
针对这三个问题,领域驱动设计都给出了自己的应对措施。
领域驱动设计的应对措施
隔离业务复杂度与技术复杂度
要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,首要任务就是确定业务逻辑与技术实现的边界,从而隔离各自的复杂度。这种隔离也是题中应有之义,毕竟技术与业务的关注点完全不同。例如,在电商的领域逻辑中,订单业务关注的业务规则包括验证订单有效性、计算订单总额、提交和审核订单的流程等;技术关注点则从实现层面保障这些业务能够正确地完成,包括确保分布式系统之间的数据一致性,确保服务之间通信的正确性等。
业务逻辑并不关心技术是如何实现的,无论采用何种技术,只要业务需求不变,业务规则就不会发生变化。换言之,在理想状态下,我们应该保证业务规则与技术实现是正交的。
领域驱动设计通过分层架构与六边形架构来确保业务逻辑与技术实现的隔离。
分层架构的关注点分离
分层架构遵循了“关注点分离”原则将属于业务逻辑的关注点放到领域层Domain Layer而将支撑业务逻辑的技术实现放到基础设施层Infrastructure Layer中。同时领域驱动设计又颇具创见的引入了应用层Application Layer应用层扮演了双重角色。一方面它作为业务逻辑的外观Facade暴露了能够体现业务用例的应用服务接口另一方面它又是业务逻辑与技术实现的粘合剂实现二者之间的协作。
下图展现的就是一个典型的领域驱动设计分层架构蓝色区域的内容与业务逻辑有关灰色区域的内容与技术实现有关二者泾渭分明然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界通过直接依赖或者依赖注入DIDependency Injection的方式将二者结合起来
六边形架构的内外分离
由 Cockburn 提出的六边形架构则以“内外分离”的方式,更加清晰地勾勒出了业务逻辑与技术实现的边界,且将业务逻辑放在了架构的核心位置。这种架构模式改变了我们观察系统架构的视角:
体现业务逻辑的应用层与领域层处于六边形架构的内核,并通过内部的六边形边界与基础设施的模块隔离开。当我们在进行软件开发时,只要恪守架构上的六边形边界,则不会让技术实现的复杂度污染到业务逻辑,保证了领域的整洁。边界还隔离了变化产生的影响。如果我们在领域层或应用层抽象了技术实现的接口,再通过依赖注入将控制的方向倒转,业务内核就会变得更加的稳定,不会因为技术选型或其他决策的变化而导致领域代码的修改。
案例:隔离数据库与缓存的访问
领域驱动设计建议我们在领域层建立资源库Repository的抽象它的实现则被放在基础设施层然后采用依赖注入在运行时为业务逻辑注入具体的资源库实现。那么对于处于内核之外的 Repositories 模块而言,即使选择从 MyBatis 迁移到 Sprint Data领域代码都不会受到牵连
package practiceddd.ecommerce.ordercontext.application;
@Transaction
public class OrderAppService {
@Service
private PlaceOrderService placeOrder;
public void placeOrder(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
try {
palceOrder.execute(buyerId, items, shipping, billing);
} catch (OrderRepositoryException | InvalidOrderException | Exception ex) {
ex.printStackTrace();
logger.error(ex.getMessage());
}
}
}
package practiceddd.ecommerce.ordercontext.domain;
public interface OrderRepository {
List<Order> forBuyerId(Identity buyerId);
void add(Order order);
}
public class PlaceOrderService {
@Repository
private OrderRepository orderRepository;
@Service
private OrderValidator orderValidator;
public void execute(Identity buyerId, List<OrderItem> items, ShippingAddress shipping, BillingAddress billing) {
Order order = Order.create(buyerId, items, shipping, billing);
if (orderValidator.isValid(order)) {
orderRepository.add(order);
} else {
throw new InvalidOrderException(String.format("the order which placed by buyer with %s is invalid.", buyerId));
}
}
}
package practiceddd.ecommerce.ordercontext.infrastructure.db;
public class OrderMybatisRepository implements OrderRepository {}
public class OrderSprintDataRepository implements OrderRepository {}
对缓存的处理可以如法炮制,但它与资源库稍有不同之处。资源库作为访问领域模型对象的入口,其本身提供的增删改查功能,在抽象层面上是对领域资源的访问。因此在领域驱动设计中,我们通常将资源库的抽象归属到领域层。对缓存的访问则不相同,它的逻辑就是对 key 和 value 的操作,与具体的领域无关。倘若要为缓存的访问方法定义抽象接口,在分层的归属上应该属于应用层,至于实现则属于技术范畴,应该放在基础设施层:
package practiceddd.ecommerce.ordercontext.application;
@Transaction
public class OrderAppService {
@Repository
private OrderRepository orderRepository;
@Service
private CacheClient<List<Order>> cacheClient;
public List<Order> findBy(Identity buyerId) {
Optional<List<Order>> cachedOrders = cacheClient.get(buyerId.value());
if (cachedOrders.isPresent()) {
return orders.get();
}
List<Order> orders = orderRepository.forBuyerId(buyerId);
if (!orders.isEmpty()) {
cacheClient.put(buyerId.value(), orders);
}
return orders;
}
}
package practiceddd.ecommerce.ordercontext.application.cache;
public interface CacheClient<T> {
Optional<T> get(String key);
void put(String key, T value);
}
package practiceddd.ecommerce.ordercontext.infrastructure.cache;
public class RedisCacheClient<T> implements CacheClient<T> {}
本例中对应的代码结构在分层架构中的体现将会在后续章节中深入介绍,敬请期待~

View File

@ -0,0 +1,127 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
007 领域驱动设计对软件复杂度的应对(下)
限界上下文的分而治之
在第1-4课中分析缓存访问接口的归属时我们将接口放在了系统的应用层。从层次的职责来看这样的设计是合理的但它却使得系统的应用层变得更加臃肿职责也变得不够单一了。这是分层架构与六边形架构的局限所在因为这两种架构模式仅仅体现了软件系统的逻辑划分。倘若我们将一个软件系统视为一个纵横交错的魔方前述的逻辑划分仅仅是一种水平方向的划分至于垂直方向的划分则是面向垂直业务的切割。这种方式更利于控制软件系统的规模将一个庞大的软件系统划分为松散耦合的多个小系统的组合。
针对前述案例我们可以将缓存视为一个独立的子系统它同样拥有自己的业务逻辑和技术实现因而也可以为其建立属于缓存领域的分层架构。在架构的宏观视角这个缓存子系统与订单子系统处于同一个抽象层次。这一概念在领域驱动设计中被称之为限界上下文Bounded Context
针对庞大而复杂的问题域,限界上下文采用了“分而治之”的思想对问题域进行了分解,有效地控制了问题域的规模,进而控制了整个系统的规模。一旦规模减小,无论业务复杂度还是技术复杂度,都会得到显著的降低,在对领域进行分析以及建模时,也能变得更加容易。限界上下文对整个系统进行了划分,在将一个大系统拆分为一个个小系统后,我们再利用分层架构与六边形架构思想对其进行逻辑分层,以确保业务逻辑与技术实现的隔离,其设计会变得更易于把控,系统的架构也会变得更加清晰。
案例:限界上下文帮助架构的演进
国际报税系统是为跨国公司的驻外出差雇员(系统中被称之为 Assignee提供方便一体化的税收信息填报平台。客户是一家会计师事务所该事务所的专员Admin通过该平台可以收集雇员提交的报税信息然后对这些信息进行税务评审。如果 Admin 评审出信息有问题,则返回给 Assignee 重新修改和填报。一旦信息确认无误,则进行税收分析和计算,并获得最终的税务报告提交给当地政府以及雇员本人。
系统主要涉及的功能包括:
驻外出差雇员的薪酬与福利
税收计划与合规评审
对税收评审的分配管理
税收策略设计与评审
对驻外出差雇员的税收合规评审
全球的 Visa 服务
主要涉及的用户角色包括:
Assignee驻外出差雇员
Admin税务专员
Client出差雇员的雇主
在早期的架构设计时架构师并没有对整个系统的问题域进行拆分而是基于用户角色对系统进行了简单粗暴的划分分为了两个相对独立的子系统Frond End 与 Office End这两个子系统单独部署分别面向 Assignee 与 Admin。系统之间的集成则通过消息和 Web Service 进行通信。两个子系统的开发分属不同的团队Frond End 由美国的团队负责开发与维护,而 Office End 则由印度的团队负责。整个架构如下图所示:
采用这种架构面临的问题如下:
庞大的代码库:整个 Front End 和 Office End 都没有做物理分解,随着需求的增多,代码库会变得格外庞大。
分散的逻辑:系统分解的边界是不合理的,没有按照业务分解,而是按照用户的角色进行分解,因而导致大量相似的逻辑分散在两个不同的子系统中。
重复的数据:两个子系统中存在业务重叠,因而也导致了部分数据的重复。
复杂的集成Front End 与 Office End 因为某些相关的业务需要彼此通信,这种集成关系是双向的,且由两个不同的团队开发,导致集成的接口混乱,消息协议多样化。
知识未形成共享:两个团队完全独立开发,没有掌握端对端的整体流程,团队之间没有形成知识的共享。
无法应对需求变化新增需求包括对国际旅游、Visa 的支持,现有系统的架构无法很好地支持这些变化。
采用领域驱动设计,我们将架构的主要关注点放在了“领域”,与客户进行了充分的需求沟通和交流。通过分析已有系统的问题域,结合客户提出的新需求,对整个问题域进行了梳理,并利用限界上下文对问题域进行了分解,获得了如下限界上下文:
Account Management管理用户的身份与配置信息
Calendar Management管理用户的日程与旅行足迹。
之后,客户希望能改进需求,做到全球范围内的工作指派与管理,目的在于提高公司的运营效率。通过对领域的分析,我们又识别出两个限界上下文。在原有的系统架构中,这两个限界上下文同时处于 Front End 与 Office End 之中,属于重复开发的业务逻辑:
Work Record Management实现工作的分配与任务的跟踪
File Sharing目的是实现客户与会计师事务所之间的文件交换。
随着我们对领域知识的逐渐深入理解与分析,又随之识别出如下限界上下文:
Consent管理合法的遵守法规的状态
Notification管理系统与客户之间的交流
Questionnaire对问卷调查的数据收集。
这个领域分析的过程实际上就是通过对领域的分析而引入限界上下文对问题域进行分解,通过降低规模的方式来降低问题域的复杂度;同时,通过为模型确定清晰的边界,使得系统的结构变得更加的清晰,从而保证了领域逻辑的一致性。一旦确定了清晰的领域模型,就能够帮助我们更加容易地发现系统的可重用点与可扩展点,并遵循“高内聚、松耦合”的原则对系统职责进行合理分配,再辅以分层架构以划分逻辑边界,如下图所示:
我们将识别出来的限界上下文定义为微服务,并对外公开 REST 服务接口。UI Applications 是一个薄薄的展现层,它会调用后端的 RESTful 服务也使得服务在保证接口不变的前提下能够单独演化。每个服务都是独立的可以单独部署因而可以针对服务建立单独的代码库和对应的特性团队Feature Team。服务的重用性和可扩展性也有了更好的保障服务与 UI 之间的集成变得更简单,整个架构会更加清晰。
领域模型对领域知识的抽象
领域模型是对业务需求的一种抽象,其表达了领域概念、领域规则以及领域概念之间的关系。一个好的领域模型是对统一语言的可视化表示,通过它可以减少需求沟通可能出现的歧义;通过提炼领域知识,并运用抽象的领域模型去表达,就可以达到对领域逻辑的化繁为简。模型是封装,实现了对业务细节的隐藏;模型是抽象,提取了领域知识的共同特征,保留了面对变化时能够良好扩展的可能性。
案例:项目管理系统的领域模型
我们开发的项目管理系统需要支持多种软件项目管理流程如瀑布、RUP、XP 或者 Scrum这些项目管理流程是迥然不同的如果需要各自提供不同的解决方案则会使得系统的模型变得非常复杂也可能会引入许多不必要的重复。通过领域建模我们可以对项目管理领域的知识进行抽象寻找具有共同特征的领域概念。这就需要分析各种项目管理流程的主要特征与表现才能从中提炼出领域模型。
瀑布式软件开发由需求、分析、设计、编码、测试、验收六个阶段构成,每个阶段都由不同的活动构成,这些活动可能是设计或开发任务,也可能是召开评审会。流程如下图所示:
RUP 清晰地划分了四个阶段先启阶段Inception、细化阶段Elaboration、构造阶段Construction与交付阶段Transition每个阶段可以包含一到多个迭代每个迭代有不同的工作如业务建模、分析设计、配置与变更管理等RUP 的流程如下图所示:
XP 作为一种敏捷方法采用了迭代的增量式开发提倡为客户交付具有业务价值的可运行软件。在执行交付计划之前XP 要求团队对系统的架构做一次预研Architectual Spike又被译为架构穿刺。当架构的初始方案确定后就可以进入每次小版本的交付。每个小版本交付又被划分为多个周期相同的迭代。在迭代过程中要求执行一些必须的活动如编写用户故事、故事点估算、验收测试等。XP 的流程如下图所示:
Scrum 同样是迭代的增量开发过程。项目在开始之初需要在准备阶段确定系统愿景、梳理业务用例、确定产品待办项Product Backlog、制定发布计划以及组建团队。一旦在确定了产品待办项以及发布计划之后就进入了 Sprint 迭代阶段。Sprint 迭代过程是一个固定时长的项目过程在这个过程中整个团队需要召开计划会议、每日站会、评审会议和回顾会议。Scrum 的流程如下图所示:
不同的项目管理流程具有不同的业务概念。例如瀑布式开发分为了六个阶段但却没有发布和迭代的概念RUP 没有发布的概念,而 Scrum 又为迭代引入了 Sprint 的概念。
不同的项目管理流程具有不同的业务规则。例如RUP 的四个阶段会包含多个迭代周期每个迭代周期都需要完成对应的工作只是不同的工作在不同阶段所占的比重不同。XP 需要在进入发布阶段之前进行架构预研而在每次小版本发布之前都需要进行验收测试和客户验收。Scrum 的 Sprint 是一个基本固定的流程,每个迭代召开的四会(计划会议、评审会议、回顾会议与每日站会)都有明确的目标。
领域建模就是要从这些纷繁复杂的领域逻辑中寻找到能够表示项目管理领域的概念,并利用面向对象建模范式或其他范式对概念进行抽象,并确定它们之间的关系。经过对这些项目管理流程的分析,我们虽然发现在业务概念和规则上确有不同之处,但由于它们都归属于软件开发领域,我们自然也能寻找到某些共同特征的蛛丝马迹。
首先从项目管理系统的角度看无论针对何种项目管理流程我们的主题需求是不变的就是要为这些管理流程制定软件开发计划Plan。不同之处在于计划可以由多个阶段Phase组成也可以由多个发布Release组成。一些项目管理流程没有发布的概念我们可以认为是一个发布。那么到底是发布包含了多个阶段还是阶段包含了多个发布呢我们发现在 XP 中明显地划分了两个阶段Architecture Spike 与 Release Planning而发布只属于 Release Planning 阶段。因而从概念内涵上我们可以认为是阶段Phase包含了发布Release。每个发布又包含了一到多个迭代Iteration至于 Scrum 的 Sprint 概念其实可以看做是迭代的一种特例。每个迭代可以开展多种不同的活动Activity这些活动可以是整个团队参与的会议也可以是部分成员或特定角色执行的实践。对于计划而言我们还需要跟踪任务Task。与活动不同任务具有明确的计划起止时间、实际起止时间、工作量、优先级与承担人。
于是,我们提炼出如下的统一领域模型:
为了项目管理者更加方便地制定项目计划,产品经理提出了计划模板功能。当管理者选择对应的项目管理生命周期类型后,系统会自动创建满足其规则的初始计划。基于该需求,我们更新了之前的领域模型:
在增加的领域模型中LifeCycle Specification 是一个隐含的概念遵循领域驱动设计提出的规格Specification模式封装了项目开发生命周期的约束规则。
领域模型以可视化的方式清晰地表达了业务含义我们可以根据这个模型来指导后面的程序设计与编码实现。当增加新的需求或者需求发生变化时我们能够敏锐地捕捉到现有模型的不匹配之处并对其进行更新。领域模型传递了知识可以作为交流的载体符合人们的心智模型有利于让开发人员从纷繁复杂的业务中解脱出来。这是领域驱动设计针对第04课中遇到的第三个问题——控制业务复杂度的解答。

View File

@ -0,0 +1,73 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
008 软件开发团队的沟通与协作
领域驱动设计的核心是“领域”,因此要运用领域驱动设计,从一开始就要让团队走到正确的点儿上。当我们组建好了团队之后,应该从哪里开始?不是 UI 原型设计、不是架构设计、也不是设计数据库,这些事情虽然重要但却非最高优先级。试想,项目已经启动,团队却并不了解整个系统的目标和范围,未对系统的领域需求达成共识,那么项目开发的航向是否会随着时间的推移而逐渐偏离?用正确的方法做正确的事情,运用领域驱动设计,就是要先识别问题域,进而为团队提炼达成共识的领域知识。
要做到这一点,就离不开团队各个角色的沟通与协作。客户的需求不是从一开始就生长在那里的,就好像在茫茫森林中的一棵树木,等待我们去“发现”它。相反,需求可能只是一粒种子,需要土壤、阳光与水分,在人们的精心呵护与培植下才能茁壮成长。因此,我们无法“发现”需求,而是要和客户一起“培育”需求,并在这个培育过程中逐渐成熟。
达成共识
“培育”需求的过程需要双向的沟通、反馈,更要达成对领域知识理解的共识。原始的需求是“哈姆雷特”,每个人心中都有一个“哈姆雷特”,如果没有正确的沟通与交流方式,团队达成的所谓“需求一致”不过是一种假象罢了。
由于每个人获得的信息不同,知识背景不同,又因为角色不同因而导致设想的上下文也不相同,诸多的不同使得我们在对话交流中好像被蒙了双眼的盲人,我们共同捕捉的需求就好似一头大象,各自只获得局部的知识,却自以为掌控了全局:
或许有人会认为客户提出的需求就应该是全部,我们只需理解客户的需求,然后积极响应这些需求即可。传统的开发合作模式更妄图以合同的形式约定需求知识,要求甲、乙双方在一份沉甸甸的需求规格说明书上签字画押,如此即可约定需求内容和边界,一旦发生超出该文档边界的变更,就需要将变更申请提交到需求变更委员会进行评审。这种方式从一开始就站不住脚,因为我们对客户需求的理解,存在三个方向的偏差:
我们从客户那里了解到的需求,并非用户最终的需求;
若无有效的沟通方式,需求的理解偏差则会导致结果大相径庭;
理解到的需求并没有揭示完整的领域知识,从而导致领域建模与设计出现认知障碍。
Jeff Patton 在《用户故事地图》中给出了一副漫画来描述共识达成的问题。我在 ThoughtWorks 给客户开展 Inception 活动时,也使用了这幅漫画:
这幅漫画形象地表现了如何通过可视化的交流形式逐渐在多个角色之间达成共识的过程。正如前面所述,在团队交流中,每个人都可能成为“盲人摸象的演员”。怎么避免认知偏差?很简单,就是要用可视化的方式表现出来,例如,绘图、使用便签、编写用户故事或测试用例等都是重要的辅助手段。在下一课,我会结合着领域场景分析来讲解这些提炼领域知识的手段。
可视化形式的交流可以让不同角色看到需求之间的差异。一旦明确了这些差异,就可以利用各自掌握的知识互补不足去掉有余,最终得到大家都一致认可的需求,形成统一的认知模型。
团队协作
在软件开发的不同阶段团队协作的方式与目标并不相同。在项目的先启Inception阶段团队成员对整个项目的需求完全一无所知此时与客户或领域专家的沟通应该主要专注于宏观层面的领域知识例如系统愿景和目标、系统边界与范围还有主要的需求功能与核心业务流程。在管理层面还需要在先启阶段确定团队与利益相关人包括客户与领域专家的沟通方式。
先启阶段
在敏捷开发过程中,我们非常重视在项目之初开展的先启阶段,尤其是有客户参与的先启阶段,是最好的了解领域知识的方法。如果团队采用领域驱动设计,就可以在先启阶段运用战略设计,建立初步的统一语言,在识别出主要的史诗级故事与主要用户故事之后,进而识别出限界上下文,并建立系统的逻辑架构与物理架构。
在先启阶段,与提炼领域知识相关的活动如下图所示:
上图列出的七项活动存在明显的先后顺序。首先我们需要确定项目的利益相关人并通过和这些利益相关人的沟通来确定系统的业务期望与愿景。在期望与愿景的核心目标指导下团队与客户才可能就问题域达成共同理解。这时我们需要确定项目的当前状态与未来状态从而确定项目的业务范围。之后就可以对需求进行分解了。在先启阶段对需求的分析不宜过细因此需求分解可以从史诗级Epic到主故事级Master进行逐层划分并最终在业务范围内确定迭代开发需要的主故事列表。
迭代开发阶段
在迭代开发阶段,针对迭代生命周期和用户故事生命周期可以开展不同形式的沟通与协作。在这个过程中,所有沟通协作的关键点如下图所示:
迭代生命周期是针对迭代目标与范围进行需求分析与沟通的过程。团队首先要了解本次迭代的目标,对迭代中的每个任务要建立基本的领域知识的理解。在迭代开发过程中,我们可以借鉴 Scrum 敏捷管理的过程。
Scrum 要求团队在迭代开始之前召开计划会议由产品负责人Product Owner在会议中向团队成员介绍和解释该迭代需要完成的用户故事包括用户故事的业务逻辑与验收标准。团队成员对用户故事有任何不解或困惑都可以通过这个会议进行沟通初步达成领域知识的共识。每天的站立会议要求产品负责人参与这就使得开发过程中可能出现的需求理解问题能够及时得到解答。Scrum Master 则通过每天的站立会议了解当前的迭代进度,并与产品负责人一起基于当前进度和迭代目标确定是否需要调整需求的优先级。迭代结束后,团队需要召开迭代演示会议,除了开发团队之外,该会议还可以邀请客户、最终用户以及领域专家参与,由团队的测试人员演示当前迭代已经完成的功能。这种产品演示的方法更容易消除用户、客户、领域专家、产品负责人与团队在需求沟通与理解上的偏差。由于迭代周期往往较短,即使发现了因为需求理解不一致导致的功能实现偏差,也能够做到及时纠偏,从而能够将需求问题扼杀于摇篮之中。
每一个功能的实现、每一行代码的编写都是围绕着用户故事开展的,它是构成领域知识的最基本单元。用户故事指导着开发人员的开发、测试人员的测试,其质量会直接影响领域驱动设计的质量。
敏捷方法非常重视发生在用户故事生命周期中的各个关键节点。对于用户故事的编写敏捷开发实践强调业务分析人员与测试人员共同编写验收测试的自动化测试脚本这在《实例化需求》一书中被称之为“活文档Living Document”。测试人员与需求分析人员的合作可以为需求分析提供更多观察视角尤其是异常场景的识别与验收标准的确认。
当用户故事从需求分析人员传递给开发人员时不管这个用户故事的描述是多么的准确和详细都有可能导致知识流失。因此在开发人员领取了用户故事并充分理解了用户故事描述的需求后不要急匆匆地开始编码实现而是建议将需求分析人员与测试人员叫过来大家一起做一个极短时间的沟通与确认我们称这一活动为“Kick Off”这种方式实际就是对“盲人摸象”问题的一种应对。在这个沟通过程中开发人员应尽可能地多问需求分析人员“为什么”以探索用户故事带来的价值。只有如此开发人员才能更好地理解业务逻辑与业务规则。同时开发人员还要与测试人员再三确认验收标准以形成一种事实上的需求规约。
当开发完成后是否就意味着我们可以将实现的故事卡移交给测试呢虽然通过迭代开发以及建立特性团队已经大大地拉近了开发人员与测试人员的距离缩短了需求从开发到测试的周期。但我们认为有价值的沟通与交流怎么强调都不过分磨刀不误砍柴工。我们认为从开发完成到测试开始也是一个关键节点建议在这个关键节点再进行一次交流活动即在开发环境下由开发人员向需求分析人员与测试人员“实地”演示刚刚完成的功能并对照着验收标准进行验收我们称这个过程为“Desk Check”是一个快速迷你的功能演示目的是快速反馈也减少了任务卡在开发与测试之间频繁切换的沟通成本。
通过 Desk Check 的用户故事卡才会被移动到“待测试”,不用等到迭代结束,更不用等到版本发布,只要开发人员完成了用户故事,测试人员就应该在迭代周期内进行测试,未经过测试的用户故事其交付价值为 0可以认为这张用户故事卡没有完成这也是大多数敏捷实践对所谓“完成Done”的定义。无数研究与实践也证明了修改 Bug 的成本会随着时间的推移而增加,如果在开发完成后即刻对其进行测试,一旦发现了 Bug开发人员便能够快速响应降低修改 Bug 的成本。当然,测试的过程同样是沟通与交流的过程,是最有效的需求验证和质量保障的手段。
敏捷思想强调个体和团队的协作与沟通,强调快速反馈与及时响应。前面探讨的这些敏捷实践都是行之有效的沟通机制和交流手段,可以帮助团队对需求的理解更加全面、更加准确。只有频繁的沟通,才能就业务需求达成整个团队的共识;只有良好的协作,才能有助于大家一起提炼领域知识,建立统一语言;只有快速反馈,才能尽可能保证领域模型与程序实现的一致。这些都是实践领域驱动设计的基本前提。

View File

@ -0,0 +1,141 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
009 运用领域场景分析提炼领域知识(上)
领域场景分析的 6W 模型
在软件构造过程中,我们必须正确地理解领域,一种生动的方式是通过“场景”来展现领域逻辑。领域专家或业务分析师从领域中提炼出“场景”,就好像是从抽象的三维球体中,切割出具体可见的一片,然后以这一片场景为舞台,上演各种角色之间的悲欢离合。每个角色的行为皆在业务流程的指引下展开活动,并受到业务规则的约束。当我们在描述场景时,就好像在讲故事,又好似在拍电影。
组成场景的要素常常被称之为 6W 模型,即描写场景的过程必须包含 Who、What、Why、Where、When 与 hoW 这六个要素6W 模型如下图所示:
通过场景分析领域需求时首先需要识别参与该场景的用户角色。我们可以为其建立用户画像Persona通过分析该用户的特征与属性来辨别该角色在整个场景中参与的活动。这意味着我们需要明确业务功能What思考这一功能给该角色能够带来什么样的业务价值Why。注意这里所谓的“角色”是参差多态的同一个用户在不同场景可能是完全不同的角色。例如在电商系统中倘若执行的是下订单功能则角色就是买家针对该订单发表评论参与的角色就变成了评论者。
在 6W 模型中我将领域功能划分为三个层次即业务价值、业务功能和业务实现我将其称之为“职责的层次”。定义为“职责Responsibility”才能够更好地体现它与角色之间的关系即“角色履行了职责”。业务价值体现了职责存在的目的即解释了该领域需求的 Why。只有提供了该职责这个场景对于参与角色才是有价值的。为了满足业务价值我们可以进一步剖析为了实现该价值需要哪些支撑功能这些业务功能对应 6W 模型中的 What。进一步我们对功能深入分析就可以分析获得具体的业务实现。业务实现关注于如何去实现该业务价值因而对应于 hoW。
在电商系统中,买家要购买商品,因而下订单这一职责是具有业务价值的。通过领域分析,结合职责的层次概念,我们就可以得到如下的职责分层结构:
下订单
验证订单是否有效
验证订单是否为空
验证订单信息是否完整
验证订单当前状态是否处于“待提交”状态
验证订单提交者是否为合法用户
验证商品库存量是否大于等于订单中的数量
基于业务规则计算订单总价、优惠与配送费
获取用户信息
获取当前促销规则
计算订单总价
计算订单优惠
计算商品配送费
提交订单
将订单项插入到数据表中
将订单插入到数据表中
更新订单状态为“待付款”
更新购物车
删除购物车中对应的商品
发送通知
给买家发送电子邮件,通知订单提交成功,等待付款
当我们获得这样的职责层次结构之后,就可以帮助我们更加细致地针对领域进行建模。在利用场景进行建模时,还要充分考虑场景的边界,即 6W 模型中的 Where。例如在“下订单”的案例中验证商品库存量的业务实现需要调用库存提供的接口该功能属于下订单场景的边界之外。领域驱动设计引入了限界上下文Bounded Context来解决这一问题。
针对问题域提炼领域知识是一个空泛的概念,业务场景分析的 6W 模型给出了具有指导意义的约束要求我们提炼的领域知识必须具备模型的六个要素这就好比两位侃侃而谈的交谈者因为有了确定的主题与话题边界一场本来是漫无目的野鹤闲云似的闲聊就变成了一次深度交流的专题高端对话。6W 模型也是对领域逻辑的一种检验,如果提炼出来的领域逻辑缺乏部分要素,就有可能忽略一些重要的领域概念、规则与约束。这种缺失会对后续的领域建模直接产生影响。正本清源,按照领域场景分析的 6W 模型去分析领域逻辑,提炼领域知识,可以从一开始在一定程度上保证领域模型的完整性。
领域场景分析的方法
我发现许多主流的领域分析方法都满足领域场景分析的 6W 模型,如果将 6W 模型看做是领域分析的抽象,那么这些领域分析方法就是对 6W 模型各种不同的实现。Eric Evans 在《领域驱动设计》一书中并没有给出提炼领域知识的方法,而是给出工程师与领域专家的对话模拟了这个过程。在领域驱动设计中,团队与领域专家的对话必须是一种常态,但要让对话变得更加高效,使不同角色对相同业务的理解能够迅速达成一致,最佳的做法还是应该在团队中形成一种相对固定的场景分析模式,这些模式包括但不限于:
用例Use Case
用户故事Use Story
测试驱动开发Test Driven Development
用例
用例Use Case的概念来自 Ivar Jacobson它帮助我们思考参与系统活动的角色即用例中所谓的“参与者Actor然后通过参与者的角度去思考为其提供“价值”的业务功能。Jacobson 认为:“用例是通过某部分功能来使用系统的一种具体的方式……因此,用例是相关事务的一个具体序列,参与者和系统以对话的方式执行这些事务。……从用户的观点来看,每个用例都是系统中一个完整序列的事件。”显然,用例很好地体现了参与者与系统的一种交互,并在这种交互中体现出完整的业务价值。
用例往往通过用例规格说明来展现这种参与者与系统的交互,详细说明该用例的顺序流程。例如,针对“买家下订单”这个用例,编写的用例规格说明如下所示:
用例名称:买家下订单
用例目的:本用例为买家提供了购买心仪商品的功能。
参与者:买家
前置条件:买家已经登录并将自己心仪的商品添加到了购物车。
基础流程:
1. 买家打开购物车
2. 买家提交订单
3. 验证订单是否有效
4. 计算订单总价
5. 计算订单优惠
6. 计算配送费
7. 系统提交订单
8. 删除购物车中对应的商品
9. 系统通过电子邮件将订单信息发送给买家
替代流程:系统验证订单无效
在第3步系统确认订单无效提示验证失败原因
替代流程:提交订单失败
在第7步系统提交订单失败提示订单失败原因
虽然文本描述的用例规格说明会更容易地被业务分析人员和开发人员使用和共享但是这种文本描述的形式其可读性较差尤其是针对异常流程较多的复杂场景非常不直观。UML 引入了用例图来表示用例,它是用例的一种模型抽象,通过可视化的方式来表示参与者与用例之间的交互,用例与用例之间的关系以及系统的边界。组成一个用例图的要素包括:
参与者Actor代表了 6W 模型的 Who
用例Use Case代表了 6W 模型的 What
用例关系包括使用、包含、扩展、泛化、特化等关系其中使用use关系代表了 Why
边界Boundary代表了 6W 模型的 Where。
通过用例图来表示上面的用例规格说明:
在这个用例图中,为什么只有 place order 用例与 buyer 参与者之间才存在使用use关系我们可以看看上图中的所有用例只有“下订单”本身对于买家而言才具有业务价值也是买家“参与”该业务场景的主要目的。因此我们可以将该用例视为体现这个领域场景的主用例其他用例则是与该主用例产生协作关系的子用例。
用例之间的协作关系主要分为两种:
包含include
扩展extend
如何理解包含与扩展之间的区别?大体而言,“包含”关系意味着子用例是主用例中不可缺少的一个执行步骤,如果缺少了该子用例,主用例可能会变得不完整。“扩展”子用例是对主用例的一种补充或强化,即使没有该扩展用例,对主用例也不会产生直接影响,主用例自身仍然是完整的。倘若熟悉面向对象设计与分析方法,可以将“包含”关系类比为对象之间的组合关系,如汽车与轮胎,是一种 must have 关系,而“扩展”关系就是对象之间的聚合关系,如汽车与车载音响,是一种 nice to have 关系。当然,在绘制用例图时,倘若实在无法分辨某个用例究竟是包含还是扩展,那就“跟着感觉走”吧,这种设计决策并非生死攸关的重大决定,即使辨别错误,几乎也不会影响到最后的设计。
无论是包含还是扩展,这些子用例都是为主用例服务,体现了用例规格描述的流程,即为 6W 模型中的 When 与 hoW。
根据用例代表的职责相关性,我们可以对用例图中的所有用例进行分类,从而划分用例的边界。确定用例相关性就是分析何谓内聚的职责,是根据关系的亲密程度来判断的。显然,上图中的 remove shopping cart items、notify buyer 与 validate inventory 与 place order 用例的关系,远不如 validate order 等用例与 place order 之间的关系紧密。因此,我们将这些用例与 order 分开,分别放到 shopping cart、notification 与 inventory 中这是用例边界Where的体现。
用例图是领域专家与开发团队可以进行沟通的一种可视化手段,简单形象,还可以避免从一开始就陷入到技术细节中——用例的关注点就是领域。
绘制用例图时,切忌闭门造车,最好让团队一起协作。用例表达的领域概念必须精准!在为每个用例进行命名时,我们都应该采纳统一语言中的概念,然后以言简意赅的动宾短语描述用例,并提供英文表达。很多时候,在团队内部已经形成了中文概念的固有印象,一旦翻译成英文,就可能呈现百花齐放的面貌,这就破坏了“统一语言”。为保证用例描述的精准性,可以考虑引入“局外人”对用例提问,局外人不了解业务,任何领域概念对他而言可能都是陌生的。通过不断对用例表达的概念进行提问,团队成员就会在不断的阐释中形成更加清晰的术语定义,对领域行为的认识也会更加精确。

View File

@ -0,0 +1,301 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
010 运用领域场景分析提炼领域知识(下)
用户故事
敏捷开发人员对用户故事User Story绝不陌生不过很多人并未想过为何极限编程的创始人 Kent Beck 要用用户故事来代替传统的“需求功能点”。传统的需求分析产生的是冷冰冰的需求文档,它把着重点放在了系统功能的精确描述上,却忽略了整个软件系统最重要的核心——用户。一个软件系统,只有用户在使用它的功能时才会真正产生价值。传统的功能描述忽略了在需求场景中用户的参与,因而缺乏了需求描写的“身临其境”。用户故事则站在了用户角度,以“讲故事”的方式来阐述需求;这种所谓“故事”其实就是对领域场景的描述,因而一个典型的用户故事,无论形式如何,实质上都是领域场景 6W 模型的体现。
一种经典的用户故事模板要求以如下格式来描述故事:
As a作为<角色>
I would like我希望<活动>
so that以便于<业务价值>
格式中的角色、活动与业务价值正好对应了 6W 模型的 Who、What 与 Why。形如这样的模板并非形式主义而是希望通过这种显式的格式来推动需求分析师站在用户角色的角度去挖掘隐藏在故事背后的“业务价值”。需求分析师要做一个好的故事讲述者就需要站在角色的角度不停地针对用户故事去问为什么。
针对如下用户故事:
作为一名用户,
我希望可以提供查询功能,
以便于了解分配给我的任务情况。
我们可以询问如下问题:
到底谁是用户?需要执行这一活动的角色到底是谁?
为什么需要查询功能?
究竟要查询什么样的内容?
为什么需要了解分配给我的任务情况?
显然前面给出的用户故事含糊不清,并没有清晰地表达业务目标。这样的用户故事并不利于我们提炼领域知识。倘若我们将用户识别为项目成员,则这个角色与项目跟踪管理这个场景才能够互相呼应。从角色入手,就可以更好地理解所谓的“业务价值”到底是什么?——项目成员希望跟踪自己的工作进度。如何跟踪工作进度?那就需要获得目前分配给自己的未完成任务。于是,前面的故事描述就应该修改为:
作为一名项目成员,
我希望获取分配给自己的未完成任务,
以便于跟踪自己的工作进度。
我以“获取”代替“查询”是不希望在用户故事中主观地认定该功能一定是通过查询获得的。“查询Query”这个词语始终还是过于偏向技术实现除非该用户故事本身就是描述搜索查询的业务。
显然,在这个用户故事中,“项目成员”是行为的发起者,“跟踪工作进度”是故事发生的“因”,是行为发起者真正关心的价值,为了获得这一价值,所以才“希望获取分配给自己的未完成任务”,是故事发生的果。通过这种深度挖掘价值,就可以帮助我们发现真正的业务功能。业务功能不是“需要提供查询功能”,而是希望系统提供“获取未完成任务”的方法。至于如何获取,则是技术实现层面的细节。
Dean Leffingwell 在《敏捷软件需求》一书中对这三部分做出了如下阐释:
角色支持对产品功能的细分,而且它经常引出其他角色的需要以及相关活动的环境;活动通常表述相关角色所需的“系统需求”;价值则传达为什么要进行相关活动,也经常可以引领团队寻找能够提供相同价值而且更少工作量的替代活动。
敏捷实践要求需求分析人员与测试人员结对编写用户故事一个完整的用户故事必须是可测试Testable因此验收标准Acceptance Criteria是用户故事不可缺少的部分。所谓“验收标准”是针对系统设立的一些满足条件因此这些标准并非测试的用例而是对业务活动的细节描述有时候甚至建议采用 Given-When-Then 模式结合场景来阐述验收标准,又或者通过实例化需求的方式,直接提供“身临其境”的案例。例如,针对电商的订单处理,需要为订单设置配送免费的总额阈值,用户故事可以编写为:
作为一名销售经理
我希望为订单设置合适的配送免费的总额阈值
以便于促进平均订单总额的提高
验收标准:
* 订单总额的货币单位应以当前国家的货币为准
* 订单总额阈值必须大于0
如果采用 Given-When-Then 模式,并通过实例化需求的方式编写用户故事,可以改写为:
作为一名销售经理
我希望为订单设置合适的配送免费的总额阈值
以便于促进平均订单总额的提高
场景1订单满足配送免费的总额阈值
Given配送免费的总额阈值设置为95元人民币
And我目前的购物车总计90元人民币
When我将一个价格为5元人民币的商品添加到购物车
Then我将获得配送免费的优惠
场景2订单不满足配送免费的总额阈值
Given配送免费的总额阈值设置为95元人民币
And我目前的购物车总计85元人民币
When我将一个价格为9元人民币的商品添加到购物篮
Then我应该被告知如果我多消费1元人民币就能享受配送免费的优惠
第一个例子的验收标准更加简洁适合于业务逻辑不是特别复杂的用户故事Given-When-Then 模式的验收标准更加详细和全面,从业务流程的角度去描述,体现了 6W 模型的 hoW但有时候显得过于冗余编写的时间成本更大这两种形式可以根据具体业务酌情选用。
编写用户故事时可以参考行为驱动开发Behavior-Driven DevelopmentBDD的实践即强调使用 DSLDomain Specific Language领域特定语言描述用户行为编写用户故事。DSL 是一种编码实现相比自然语言更加精确又能以符合领域概念的形式满足所谓“活文档Living Document”的要求。
行为驱动开发的核心在于“行为”。当业务需求被划分为不同的业务场景并以“Given-When-Then”的形式描述出来时就形成了一种范式化的领域建模规约。使用领域特定语言编写用户故事的过程就是不断发现领域概念的过程。这些领域概念会因为在团队形成共识而成为统一语言。这种浮现领域模型与统一语言的过程又反过来可以规范我们对用户故事的编写即按照行为驱动开发的要求将核心放在“领域行为”上。这就需要避免两种错误的倾向
从 UI 操作去表现业务行为
描述技术实现而非业务需求
例如,我们要编写“发送邮件”这个业务场景的用户故事,可能会写成这样:
Scenario: send email
Given a user "James" with password "123456"
And I sign in
And I fill in "[email protected]" in "to" textbox
And fill in "test email" in "subject" textbox
And fill in "This is a test email" in "body" textarea
When I click the "send email" button
Then the email should be sent sucessfully
And shown with message "the email is sent sucessfully"
该用户故事描写的不是业务行为,而是用户通过 UI 进行交互的操作流程,这种方式实则是让用户界面捆绑了你对领域行为的认知。准确地说,这种 UI 交互操作并非业务行为,例如上述场景中提到的 button 与 textbox 控件,与发送邮件的功能并没有关系。如果换一个 UI 设计,使用的控件就完全不同了。
那么换成这样的写法呢?
Scenario: send email
Given a user "James" with password "123456"
And I sign in after OAuth authentification
And I fill in "[email protected]" as receiver
And "test email" as subject
And "This is a test email" as email body
When I send the email
Then it should connect smtp server
And all messages should be composed to email
And a composed email should be sent to receiver via smtp protocal
该用户故事的编写暴露了不必要的技术细节,如连接到 smtp 服务器、消息组合为邮件、邮件通过 smtp 协议发送等。我们在编写用户故事时应该按照行为驱动开发的要求关注于做什么what而不是怎么做how。如果在业务分析过程中纠缠于技术细节就可能导致我们忽略了业务价值。在业务建模阶段业务才是重心不能舍本逐末。
那么,该怎么写?
编写用户故事时,不要考虑任何 UI 操作,甚至应该抛开已设计好的 UI 原型,也不要考虑任何技术细节,不要让这些内容来干扰你对业务需求的理解。如果因为更换 UI 设计和调整 UI 布局,又或者因为改变技术实现方案,而需要修改编写好的用户故事,那就是不合理的。用户故事应该只受到业务规则与业务流程变化的影响。
让我们修改前面的用户故事,改为专注领域行为的形式编写:
Scenario: send email
Given a user "James" with password "123456"
And I sign in
And I fill in a subject with "test email"
And a body with "This is a test email"
When I send the email to "Mike" with address "[email protected]"
Then the email should be sent sucessfully
只要发送邮件的流程与规则不变,这个用户故事就不需要修改。
测试驱动开发
测试驱动开发看起来与提炼领域知识风马牛不相及,那是因为我们将测试驱动开发固化为了一种开发实践。测试驱动开发强调“测试优先”,但实质上这种“测试优先”其实是需求分析优先,是任务分解优先。测试驱动开发强调,开发人员在分析了需求之后,并不是一开始就编写测试,而是必须完成任务分解。对任务的分解其实就是对职责的识别,且识别出来的职责在被分解为单独的任务时,必须是可验证的。
在进行测试驱动开发时,虽然要求从一开始就进行任务分解,但并不苛求任务分解是完全合理的。随着测试的推进,倘若我们觉察到一个任务有太多测试用例需要编写,则意味着分解的任务粒度过粗,应对其进行再次分解;也有可能会发现一些我们之前未曾发现的任务,则需要将它们添加到任务列表中。
例如,我们要实现一个猜数字的游戏。游戏有四个格子,每个格子有 0~9 的数字,任意两个格子的数字都不一样。玩家有 6 次猜测的机会,如果猜对则获胜,失败则进入下一轮直到六轮猜测全部结束。每次猜测时,玩家需依序输入 4 个数字程序会根据猜测的情况给出形如“xAxB”的反馈。A 前面的数字代表位置和数字都对的个数B 前面的数字代表数字对但位置不对的个数。例如,答案是 1 2 3 4那么对于不同的输入会有如下的输出
输入
输出
说明
1 5 6 7
1A0B
1 位置正确
2 4 7 8
0A2B
2 和 4 位置都不正确
0 3 2 4
1A2B
4 位置正确2 和 3 位置不正确
5 6 7 8
0A0B
没有任何一个数字正确
4 3 2 1
0A4B
4 个数字位置都不对
1 2 3 4
4A0B
胜出 全中
1 1 2 3
输入不正确,重新输入
1 2
输入不正确,重新输入
答案在游戏开始时随机生成,只有 6 次输入的机会。每次猜测时,程序会给出当前猜测的结果,如果猜测错误,还会给出之前所有猜测的数字和结果以供玩家参考。输入时,用空格分隔数字。
针对猜数字游戏的需求,我们可以分解出如下任务:
随机生成答案
判断每次猜测的结果
检查输入是否合法
记录并显示历史猜测数据
判断游戏结果。判断猜测次数,如果满 6 次但是未猜对则判负;如果在 6 次内猜测的 4 个数字值与位置都正确,则判胜
当在为分解的任务编写测试用例时,不应针对被测方法编写单元测试,而应该根据领域场景进行编写,这也是为何测试驱动开发强调测试优先的原因。由于是测试优先,事先没有被测的实现代码,就可以规避这种错误方式。
编写测试的过程是进一步理解领域逻辑的过程,更是驱动我们去寻找领域概念的过程。由于在编写测试的时候,没有已经实现的类,这就需要开发人员站在调用者的角度去思考,即所谓“意图导向编程”。从调用的角度思考,可以驱动我们思考并达到如下目的:
如何命名被测试类以及方法,才能更好地表达设计者的意图,使得测试具有更好的可读性;
被测对象的创建必须简单,这样才符合测试哲学,从而使得设计具有良好的可测试性;
测试使我们只关注接口,而非实现;
在编写测试方法时,应遵循 Given-When-Then 模式这种方式描述了测试的准备、期待的行为以及验收条件。Given-When-Then 模式体现了 TDD 对设计的驱动力:
当编写 Given 时,“驱动”我们思考被测对象的创建,以及它与其他对象的协作;
当编写 When 时,“驱动”我们思考被测接口的方法命名,以及它需要接收的传入参数;考虑行为方式,究竟是命令式还是查询式方法;
当编写 Then 时,“驱动”我们分析被测接口的返回值。
例如针对任务“判断每次的猜测结果”我们首先要考虑由谁来执行此任务。从面向对象设计的角度来讲这里的任务即“职责”我们要找到职责的承担者。从拟人化的角度去思考所谓“对象”就是要找到能够彻底理解understand该职责的对象。基于这样的设计思想驱动我们获得了 Game 对象。进一步分析任务由于我们需要判断猜测结果这必然要求获知游戏的答案从而寻找出表达了猜测结果这一领域知识的概念Answer这实际上就是以测试驱动的方式来帮助我们进行领域建模。
编写 When 可以帮助开发者思考类的行为,一定要从业务而非实现的角度去思考接口。例如:
实现角度的设计check()
业务角度的设计guess()
注意两个方法命名表达意图的不同,显然后者更好地表达了领域知识。
编写 Then 考虑的是如何验证,没有任何验证的测试不能称其为测试。由于该任务为判断输入答案是否正确,并获得猜测结果,因而必然需要返回值。从需求来看,只需要返回一个形如 xAxB 的字符串即可。通过 Given-When-Then 模式组成了一个测试方法所要覆盖的领域场景,而测试方法自身则以描述业务的形式命名。例如,针对“判断每次猜测的结果”任务,可以编写其中的一个测试方法:
@Test
public void should_return_0A0B_when_no_number_is_correct() {
//given
Answer actualAnswer = Answer.createAnswer("1 2 3 4");
Game game = new Game(actualAnswer);
Answer inputAnswer = Answer.createAnswer("5 6 7 8");
//when
String result = game.guess(inputAnswer);
//then
assertThat(result , is("0A0B"));
}
测试方法名可以足够长,以便于清晰地表述业务。为了更好地辨别方法名表达的含义,我们提倡用 Ruby 风格的命名方法,即下划线分隔方法的每个单词,而非 Java 传统的驼峰风格。建议测试方法名以 should 开头,此时,默认的主语为被测类,即这里的 Game。因此该测试方法就可以阅读为Game should return 0A0B when no number guessed correctly。显然这是一条描述了业务规则的自然语言。
这三种方法各有风格,驱动领域场景的力量也各自不同,甚至这些方法在开发实践中并非处于同一个维度,然而在领域场景分析这个大框架下,又都直接或间接体现了场景的 6W 模型。当然,这里展现的仅仅是这些方法的冰山一角,讲解的侧重点还是在于通过这些方法来帮助我们提炼领域知识。同时,借助类似用例、用户故事、任务等载体,可以更加有效而直观地帮助我们理解问题域,抽象领域模型,从而为我们建立统一语言奠定共识基础。
提炼领域知识
提炼领域知识需要贯穿整个领域驱动设计全过程,无论何时,都必须重视领域知识,并时刻维护统一语言。在进行领域场景分析时,这是一个双向的过程。一方面,我们已提炼出来的领域知识会指导我们识别用例,编写用户故事以及测试用例;另一方面,具体的领域场景分析方法又可以进一步帮助我们确认领域知识,并将在团队内达成共识的统一语言更新到之前识别的领域知识中。
这种双向的指导与更新非常重要,因为我们提炼的领域知识以及统一语言是领域模型的重要源头。“问渠那得清如许,为有源头活水来。”,只有源头保证了常新,领域模型才能保证健康,才能更好地指导领域驱动设计。
通过前面对用例、用户故事与测试驱动开发的介绍,我们发现这三个方法虽然都是领域场景分析的具体实现,但它们在运用层次上各有其优势。用例尤其是用例图的抽象能力更强,更擅长于对系统整体需求进行场景分析;用户故事提供了场景分析的固定模式,善于表达具体场景的业务细节;测试驱动开发则强调对业务的分解,利用编写测试用例的形式驱动领域建模,即使不采用测试先行,让开发者转换为调用者角度去思考领域对象及行为,也是一种很好的建模思想与方法。
在提炼领域知识的过程中,我们可以将这三种领域场景分析方法结合起来运用,在不同层次的领域场景中选择不同的场景分析方法,才不至于好高骛远,缺乏对细节的把控,也不至于一叶障目,只见树木不见森林。

View File

@ -0,0 +1,148 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
011 建立统一语言
统一语言是提炼领域知识的产出物,获得统一语言就是需求分析的过程,也是团队中各个角色就系统目标、范围与具体功能达成一致的过程。
使用统一语言可以帮助我们将参与讨论的客户、领域专家与开发团队拉到同一个维度空间进行讨论,若没有达成这种一致性,那就是鸡同鸭讲,毫无沟通效率,相反还可能造成误解。因此,在沟通需求时,团队中的每个人都应使用统一语言进行交流。
一旦确定了统一语言,无论是与领域专家的讨论,还是最终的实现代码,都可以通过使用相同的术语,清晰准确地定义领域知识。重要的是,当我们建立了符合整个团队皆认同的一套统一语言后,就可以在此基础上寻找正确的领域概念,为建立领域模型提供重要参考。
统一语言体现在两个方面:
统一的领域术语
领域行为描述
统一的领域术语
形成统一的领域术语,尤其是基于模型的语言概念,是沟通能够达成一致的前提。尤其是开发人员与领域专家之间,他们掌握的知识存在巨大的差异。善于技术的开发人员关注于数据库、通信机制、集成方式与架构体系,而精通业务的领域专家对这些却一窍不通,但他们在讲解业务知识时,使用各种概念如呼吸一般自然,这些对于开发人员来说,却成了天书,这种交流就好似使用两种不同语言的外国人在交谈。记得有一次我去洛杉矶出差,居住期间,需要到一家洗衣店干洗衣服,交付完衣服后,我想向洗衣店老板索要收据,以作为之后领取衣服的凭证。好不容易在我脑中贫瘠的英文词典里搜索到 receipt 这个词语,自以为正确,谁知道讲出来后老板一脸茫然,不是 receipt那么是 ……invoice手舞足蹈说了半天老板才反应过来递过来一张收据嘴里吐出 ticket 这个词语My God受了中学英语的流毒我还以为 ticket 这个词语只能用到电影院呢。
显然,从需求中提炼出统一语言,其实就是在两个不同的语言世界中进行正确翻译的过程。
某些领域术语是有行业规范的,例如财会领域就有标准的会计准则,对于账目、对账、成本、利润等概念都有标准的定义,在一定程度上避免了分歧。然而,标准并非绝对的,在某些行业甚至存在多种标准共存的现象。以民航业的运输统计指标为例,牵涉到与运量、运力以及周转量相关的术语,就存在 ICAOInternational Civil Aviation Organization国际民用航空组织与IATAInternational Air Transport Association国际航空运输协会两大体系而中国民航局又有自己的中文解释航空公司和各大机场亦有自己衍生的定义。
例如针对一次航空运输的运量就要分为城市对与航段的运量统计。城市对运量统计的是出发城市到目的城市两点之间的旅客数量机场将其称之为流向。ICAO 定义的领域术语为 City-pairOFOD而 IATA 则命名为 O & D。航段运量又称为载客量指某个特定航段上所承载的旅客总数量ICAO将其定义为 TFSTraffic by flight stage而 IATA 则称为 Segment Traffic。
即使针对航段运量这个术语,我们还需要明确地定义这个运量究竟指的是载客量,还是包含了该航段上承载的全部旅客、货物与邮件数量;我们还需要明确城市对与航段之间的区别,它们在指标统计时,实则存在细微的差异,一不小心忽略,结果就可能谬以千里。以航班 CZ5724 为例,该航班从北京(目的港代码 PEK出发经停武汉目的港代码 WUH飞往广州目的港代码 CAN。假定从北京到武汉的旅客数为 105从北京到广州的旅客数为 14从武汉到广州的旅客数为 83则统计该次航班的城市对运量应该分为三个城市对分别统计即统计 PEK-WUH、PEK-CAN、WUH-CAN。而航段运量的统计则仅仅分为两个航段 PEK-WUH 与 WUH-CAN至于从北京到广州的 14 名旅客,这个数量值则被截分为了两段,分别计数,如下图所示:
显然,如果我们不明白城市对运量与航段运量的真正含义,就可能混淆这两种指标的统计计算规则。这种术语理解错误带来的缺陷往往难以发现,除非业务分析人员、开发人员与测试人员能就此知识达成一致的正确理解。
在领域建模过程中,我们往往需要在文档中建立一个大家一致认可的术语表。术语表中需要包括整个团队精炼出来的术语概念,以及对该术语的清晰明白的解释。若有可能,可以为难以理解的术语提供具体的案例。该术语表是领域建模的关键,是模型的重要参考规范,能够真实地反应模型的领域意义。一旦发生变更,也需要及时地对其进行更新。
在维护领域术语表时,一定需要给出对应的英文术语,否则可能直接影响到代码实现。在我们的一个产品开发中,根据需求识别出了“导入策略”的领域概念。由于这个术语非常容易理解,团队就此达成了一致,却没有明确给出英文名称,最后导致前端和后端在开发与“导入策略”有关的功能时,分别命名为 ImportingPolicy 与 ImportingStrategy人为地制造了混乱。
即使术语的英语并不需要对外暴露给用户,我们仍然需要引起重视,就算不强调英文翻译的纯正,也必须保证概念的一致性,倘若认为英文表达不合理或者不标准,牵涉到对类、方法的重命名,则需要统一修改。在大数据分析领域中,针对“维度”与“指标”两个术语,我们在过去开发的产品中就曾不幸地衍生出了两套英文定义,分别为 Dimension 与 MetricCategory 与 Measure这种混乱让整个团队的开发成员痛苦不堪带来了沟通和交流的障碍。就我而言我宁愿代码命名没有正确地表达领域概念也不希望出现命名上的不一致性。倘若在建模之初就明确母语和英语的术语表达就可以做到正本清源
领域行为描述
从某种程度讲,领域行为描述可以视为领域术语甄别的一种延伸。领域行为是对业务过程的描述,相对于领域术语而言,它体现了更加完整的业务需求以及复杂的业务规则。在描述领域行为时,需要满足以下要求:
从领域的角度而非实现角度描述领域行为
若涉及到领域术语,必须遵循术语表的规范
强调动词的精确性,符合业务动作在该领域的合理性
要突出与领域行为有关的领域概念
例如,在项目管理系统中,倘若我们采用 Scrum 的敏捷项目管理流程,要描述 Sprint Backlog 的任务安排,则编写的用户故事如下所示:
作为一名Scrum Master
我希望将Sprint Backlog分配给团队成员
以便于明确Backlog的负责人并跟踪进度。
验收标准:
* 被分配的Sprint Backlog没有被关闭
* 分配成功后,系统会发送邮件给指定的团队成员
* 一个Sprint Backlog只能分配给一个团队成员
* 若已有负责人与新的负责人为同一个人,则取消本次分配
* 每次对Sprint Backlog的分配都需要保存以便于查询
用户故事中的分配assignSprint Backlog 给团队成员就是一种领域行为,这种行为是在特定上下文中由角色触发的动作,并由此产生的业务流程和操作结果。同时,这种领域行为还是一种契约,明确地表达了服务提供者与消费者之间的业务关系,即明确了领域行为的前置条件、执行主语和宾语以及行为的执行结果,这些描述丰富了该领域的统一语言,并直接影响了 API 的设计。例如,针对分配 Sprint Backlog 的行为,用户故事就明确了未关闭的 SprintBacklog 只能分配给一个团队成员,且不允许重复分配,这体现了分配行为的业务规则。验收标准中提出对分配的保存,实际上也帮助我们得到了一个领域概念 SprintBacklogAssignment该行为的代码实现如下所示
package practiceddd.projectmanager.scrumcontext.domain;
import practiceddd.projectmanager.dddcore.Entity;
import practiceddd.projectmanager.scrumcontext.domain.exception.InvalidAssignmentException;
import practiceddd.projectmanager.scrumcontext.domain.exception.InvalidBacklogException;
import practiceddd.projectmanager.scrumcontext.domain.role.MemberId;
import practiceddd.projectmanager.scrumcontext.domain.role.TeamMember;
public class SprintBacklog extends Entity<BacklogId> {
private String title;
private String description;
private BacklogStatus backlogStatus;
private MemberId ownerId;
public SprintBacklog(BacklogId backlogId, String title, String description) {
if (title == null) {
throw new InvalidBacklogException("the title of backlog can't be null");
}
this.id = backlogId;
this.title = title;
this.description = description;
this.backlogStatus = new NewBacklogStatus();
}
public SprintBacklogAssignment assignTo(TeamMember assignee) {
if (this.backlogStatus.isClosed()) {
throw new InvalidAssignmentException(
String.format("The closed sprint backlog %s can not be assigned to %s.", this.title, assignee.getName()));
}
if (assignee.isSame(this.ownerId)) {
throw new InvalidAssignmentException(
String.format("The sprint backlog %s not allow to assign to same team member %s.", this.title, assignee.getName()));
}
return new SprintBacklogAssignment(this.id, assignee.id());
}
}
基于“信息专家模式”SprintBacklog 类的 assignTo() 方法只承担了它能够履行的职责。作为 SprintBacklog 对象自身,它知道自己的状态,知道自己是否被分配过,分配给谁,也知道遵循不同的业务规则会导致产生不同的结果。但由于它不具备发送邮件的知识,针对邮件发送它就无能为力了,因此这里实现的 assignTo() 方法仅仅完成了部分领域行为,若要完成整个用户故事描述的业务场景,需要交给领域服务 AssignSprintBacklogService 来完成:
package practiceddd.projectmanager.scrumcontext.domain;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.projectmanager.scrumcontext.domain.role.TeamMember;
import practiceddd.projectmanager.scrumcontext.interfaces.notification.NotificationService;
@Service
public class AssignSprintBacklogService {
@Autowired
private SprintBacklogRepository backlogRepository;
@Autowired
private SprintBacklogAssignmentRepository assignmentRepository;
@Autowired
private NotificationService notificationService;
public void assign(SprintBacklog backlog, TeamMember assignee) {
SprintBacklogAssignment assignment = backlog.assignTo(assignee);
backlogRepository.update(backlog);
assignmentRepository.add(assignment);
AssignmentNotification notification = new AssignmentNotification(assignment);
notificationService.send(notification.address(), notification.content());
}
}
注意:我在这里将发送邮件的行为定义为领域行为,因此分配 Sprint Backlog 的业务行为被定义在了领域服务 AssignSprintBacklogService 中。如果将发送邮件视为是一种横切关注点,正确的做法则是将发送邮件的调用放到应用服务 SprintBacklogAppService 中。当然,一旦将该逻辑放到了应用服务,就存在如何组装邮件内容的问题,即前述方法中对 AssignmentNotification 实例的创建。针对这些疑问和解决方案在后续内容都有详细介绍。
定义和确定统一语言,将有利于消除领域专家与团队、以及团队成员之间沟通的分歧与误解,使得各种角色能够在相同的语境下行事,避免盲人摸象的“视觉”障碍。领域的统一语言还是领域建模的重要输入与基础,无论是采用“名词动词法”进行领域建模,还是“四色建模法”或“职责驱动建模”,统一语言都是确定模型的重要参考。如果在确定统一语言的同时,针对领域概念与领域行为皆以英文来表达,就直接为编码实现提供了类、方法、属性等命名的依据,保证代码自身就能直观表达领域含义,提高代码可读性。
磨刀不误砍柴工,多花一些时间去打磨统一语言,并非时间的浪费,相反还能改进领域模型乃至编码实现的质量,反过来,领域模型与实现的代码又能避免统一语言的“腐化”,保持语言的常新。重视统一语言,就能促成彼此正面影响的良性循环;否则领域模型与代码会因为沟通不明而泥足深陷,就真是得不偿失了。

View File

@ -0,0 +1,118 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
012 理解限界上下文
理解限界上下文的定义
什么是限界上下文Bounded Context让我们来读一个句子
wǒ yǒu kuài dì
到底是什么意思?
我们能确定到底是哪个意思吗?确定不了!!! 我们必须结合说话人的语气与语境来理解,例如:
wǒ yǒu kuài dìzǔ shàng liú xià lái de → 我有块地,祖上留下来的。
wǒ yǒu kuài dìshùn fēng de → 我有快递,顺丰的。
在日常的对话中说话的语气与语境就是帮助我们理解对话含义的上下文Context。当我们在理解系统的领域需求时同样需要借助这样的上下文而限界上下文的含义就是用一个清晰可见的边界Bounded将这个上下文勾勒出来如此就能在自己的边界内维持领域模型的一致性与完整性。Eric Evans 用细胞来形容限界上下文,因为“细胞之所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”这里,细胞代表上下文,而细胞膜代表了包裹上下文的边界。
因此,若要理解限界上下文,就需要从 Bounded 与 Context 这两个单词的含义来理解Context 表现了业务流程的场景片段。整个业务流程由诸多具有时序的活动组成随着流程的进行不同的活动需要不同的角色参与并导致上下文因为某个活动的产生随之发生切换。因而上下文Context其实是动态的业务流程被边界Bounded静态切分的产物。
假设有这样一个业务场景我作为一名咨询师从成都出发前往深圳为客户做领域驱动咨询无论是从家乘坐地铁到达成都双流机场还是乘坐飞机到达深圳宝安再从宝安机场乘坐出租车到达酒店我的身份都是一名乘客Passenger虽然因为交通工具的不同参与的活动也不尽相同但无论上车、下车还是办理登机手续、安检、登机和下机等活动终归都与交通出行有关。那么我坐在交通工具上就一定代表我属于这个上下文吗未必注意在交通出行上下文中其实模糊了“我”这个概念强调了“乘客”这个概念这是参与到该上下文的角色Role或者说“身份”。
例如我在飞机上忽然想起给客户提供的咨询方案还需要完善于是我拿出电脑在一万米高空上继续思考我的领域驱动设计方案这时的我虽然还在飞机上身份却切换成了一名咨询师Consultant。当我作为乘客乘坐出租车前往酒店并到前台办理入住手续时我又“撕下了乘客的面具”摇身一变成为了酒店的宾客Guest。次日早晨我在酒店餐厅用完早餐后离开酒店前往客户公司。随着我走出酒店这个活动的发生酒店上下文又切换回交通出行。当我到达客户所在地时面对客户我开始以一名咨询师身份与客户团队交谈了解他们的咨询目标与现有痛点。我制定咨询计划与方案并与客户一起评审咨询方案这时的上下文就切换为咨询工作了。巧合的是无论是交通出行还是酒店都需要支付费用支付的费用虽然不同支付的行为也有所差别需要用到的领域知识却是相同的因此这个活动又可以归为支付上下文。
上下文在流程中的切换犹如电影画面的场景切换,相同的人物扮演了不同的角色,在不同的上下文参与了不同的活动。由于活动的目标发生了改变,履行的职责亦有所不同,上述场景如下图所示:
整个业务流程由诸多活动Actions组成参与这些活动的有不同的角色。在每一个上下文中角色与角色之间通过活动产生协作以满足业务流程的需求。这些活动是分散的活动的目标也不相同但在同一个上下文中这些活动却是为同一个目标提供服务。
因此,在理解限界上下文时,我们需要重视几个关键点:
知识:不同的限界上下文需要的领域知识是不相同的,这实则就是业务相关性,参与到限界上下文中的活动也与“知识”有关。如果执行该活动却不具备对应知识,则说明对活动的分配不合理;如果该活动的目标与该限界上下文保持一致,却缺乏相应知识,则说明该活动需要与别的限界上下文协作。
角色:一定要深入思考参与到这个上下文的对象究竟扮演了什么样的角色,以及角色与角色在这个上下文中是如何协作的。
边界:限界上下文按照不同关注点进行分离,各自的边界则根据耦合关系的强弱来确定,越是关系最弱的地方,越是需要划定边界。
我们需要根据业务相关性、耦合的强弱程度、分离的关注点对这些活动进行归类找到不同类别之间存在的边界这就是限界上下文的含义。上下文Context是业务目标限界Bounded则是保护和隔离上下文的边界避免业务目标的不单一而带来的混乱与概念的不一致。
理解限界上下文的价值
Eric Evans 是在战略设计中引入限界上下文概念的,他认为:
既然无法维护一个涵盖整个企业的统一模型,那就不要再受到这种思路的限制。通过预先决定什么应该统一,并实际认识到什么不能统一,我们就能够创建一个清晰的、共同的视图,然后需要用一种方式来标记出不同模型之间的边界和关系。
为了解决多个模型的问题,我们需要明确地定义模型的范围——模型的范围是软件系统中一个有界的部分,这部分只应用一个模型,并尽可能使其保持统一。团队组织中必须一致遵守这个定义。
明确地定义模型所应用的上下文。根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界,在这些边界中严格保持模型的一致性,而不要受到边界之外问题的干扰和混淆。
基于以上引用的三段描述,我们可以清晰地勾勒出 Eric Evans 对于限界上下文的着眼点,那就是对边界的控制。倘若将上下文视为一国,则领域之王就应该捍卫国土疆域,国界内的一寸一尺之地都是神圣不可侵犯的。因而,我们要理解限界上下文的价值,就须得从边界来理解。
观察角度的不同,限界上下文划定的边界也有所不同。大体可以分为如下三个方面:
领域逻辑层面:限界上下文确定了领域模型的业务边界,维护了模型的完整性与一致性,从而降低系统的业务复杂度。
团队合作层面:限界上下文确定了开发团队的工作边界,建立了团队之间的合作模式,避免团队之间的沟通变得混乱,从而降低系统的管理复杂度。
技术实现层面:限界上下文确定了系统架构的应用边界,保证了系统层和上下文领域层各自的一致性,建立了上下文之间的集成方式,从而降低系统的技术复杂度。
这三种边界体现了限界上下文对不同边界的控制力。业务边界是对领域模型的控制,工作边界是对开发协作的控制,应用边界是对技术风险的控制。引入限界上下文的目的,其实不在于如何划分边界,而在于如何控制边界。
我曾经有机会向 EventStorming 的创始人 Alberto Brandolini 请教他对限界上下文的理解他做了一个非常精彩的总结bounded context are a mean of safety限界上下文意味着安全。这里的 safety 做何解呢他的意思是being in control and no surprise对限界上下文是可控制的就意味着你的系统架构与组织结构都是可控的没有出乎意料的惊讶虽然显得不够浪漫但其实只有这样才能使得团队避免过大的压力。Alberto 告诉我:
Surprise leads to stress and stress leads to no learning, just hard work. (出乎意料的惊讶会导致压力,而压力就会使得团队疲于加班,缺少学习。)
这是真正看破限界上下文本质的大师高论显然限界上下文并不是像大多数程序员理解的那样是模块、服务、组件或者子系统而是你对领域模型、团队合作以及技术风险的控制。在《Entity Framework 模型在领域驱动设计限界上下文中的应用》一文中,作者 Juelie Lerman 认为:“当开发一个具有大型领域模型的超大规模的应用程序时,与设计一个单一的大领域模型相比,将大领域模型根据应用程序的业务需要“切割”成一系列较小的模型是非常重要的,我们也往往能够从中获得更多的好处。”她还提到:“更小的模型为我们的软件设计和开发带来了更多的好处,它使得团队能够根据自己的设计和开发职责确定更为明确的工作边界。小的模型也为项目带来了更好的可维护性:由于上下文由边界确定,因此对其的修改也不会给整个模型的其他部分造成影响。”显然,通过限界上下文对领域模型进行分解,就能保证在其边界内创建的模型内聚性更高,在边界隔离下,受到变化的影响也更小,反映为团队合作的工作边界,就更容易保证团队之间的沟通与协作。
限界上下文是“分而治之”架构原则的体现我们引入它的目的其实为了控制应对软件的复杂度它并非某种固定的设计单元我们不能说它就是模块、服务或组件而是通过它来帮助我们做出高内聚低耦合的设计。只要遵循了这个设计则限界上下文就可能成为模块、服务或组件。所以文章《Bounded Contexts as a Strategic Pattern Beyond DDD》才会写到“限界上下文体现的是高层的抽象机制它并非编程语言或框架的产出工件而是体现了人们对领域思考的本质。”
宋代禅宗大师青原行思提出参禅的三重境界:
参禅之初:看山是山,看水是水;
禅有悟时:看山不是山,看水不是水;
禅中彻悟:看山仍然山,看水仍然是水。
我觉得理解限界上下文与模块、服务或组件的关系,似乎也存在这三重境界:
参悟之初:模块、服务或组件就是限界上下文。
当有悟时:模块、服务或组件不是限界上下文。
彻底悟透:模块、服务或组件仍然是限界上下文。
能理解吗?——更糊涂了!好吧,以上三重境界纯属忽悠,还是让我上一点干货吧。注意了,我要提到一个重要的概念,就是“自治”,抛开模块、服务或组件对你的影响,请大家先把限界上下文看做是一个“自治”的单元。所谓“自治”就是满足四个特征:最小完备、稳定空间、自我履行、独立进化。如下图所示的自治单元就是限界上下文,映射到编码实现,则可能是模块、组件或服务:
最小完备是实现“自治”的基本条件。所谓“完备”,是指自治单元履行的职责是完整的,无需针对自己的信息去求助别的自治单元,这就避免了不必要的依赖关系。而“最小完备”则进一步地限制了完备的范围,避免将不必要的职责被错误地添加到该自治单元上。对于限界上下文而言,就是要根据业务价值的完整性进行设计。例如,对于支付上下文,其业务价值就是“安全地完成在线支付业务”,那么在确定限界上下文的时候,就应该以完成该业务价值的最小功能集为设计边界。
自我履行意味着由自治单元自身决定要做什么。从拟人的角度来思考,就是这些自治单元能够对外部请求做出符合自身利益的明智判断,是否应该履行该职责,由限界上下文拥有的信息来决定。例如,可以站在自治单元的角度去思考:“如果我拥有了这些信息,我究竟应该履行哪些职责?”这些职责属于当前上下文的活动范围,一旦超出,就该毫不犹豫地将不属于该范围的请求转交给别的上下文。例如,在当订单上下文履行了验证订单的职责之后,需要执行支付活动时,由于与支付相关的业务行为要操作的信息已经超出了订单上下文的范畴,就应该将该职责转移到支付上下文。自我履行其实意味着对知识的掌握,为避免风险,你要履行的职责一定是你掌握的知识范畴之内。
稳定空间指的是减少外界变化对限界上下文内部的影响。自治的设计就是要划定分属自己的稳定空间让自治单元拥有空间内的掌控权保持空间的私密性开放空间接口应对外部的请求。划分自治空间需要找到限界上下文之间的间隙处然后依势而为沿着间隙方向顺势划分而所谓“间隙”其实就是依赖最为薄弱之处。例如在电商系统中管理商品上架、下架与评价商品都与商品直接相关但显然评价商品与商品的依赖关系更弱。倘若需要分解限界上下文保证上下文的稳定性就可以将评价商品的职责从商品上下文中分离出去但却不能分离商品上架和下架功能。稳定空间符合开放封闭原则OCP即对修改是封闭的对扩展是开放的该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏开放空间体现为对共性特征的抽象与统一二者共同确保了整个空间的稳定。
独立进化与稳定空间刚好相反,指的是减少限界上下文的变化对外界的影响。如果借用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动;独立进化寓意上游限界上下文,无论下游有多少,我凌寒独自开。实现上看,要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口往往被众多消费者使用,一旦修改,就会牵一发而动全身。一个独立进化的限界上下文,需要接口设计良好,符合标准规范,并在版本上考虑了兼容与演化。
自治的这四个要素是相辅相成的。最小完备意味着职责是完备的,从而减少了变化的可能;自我履行意味着自治单元能够智能地判断行为是否应该由其履行,当变化发生时,也能聪明审慎地做出合理判断;稳定空间通过隐藏细节和开放抽象接口来封装变化;独立进化则通过约束接口的规范与版本保证内部实现的演化乃至于对实现进行全面地替换。最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。
这四个要素又是高内聚低耦合思想的体现。我们需要根据业务关注点和技术关注点,尽可能将强相关性的内容放到同一个限界上下文中,同时降低限界上下文之间的耦合。对于整个系统架构而言,不同的限界上下文可以采用不同的架构风格与技术决策,而在每个限界上下文内部保持自己的技术独立性与一致性。由于限界上下文边界对技术实现的隔离,不同限界上下文内部实现的多样性并不会影响整体架构的一致性。

View File

@ -0,0 +1,99 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
013 限界上下文的控制力(上)
既然我们认为引入限界上下文的目的不在于如何划分而在于如何控制边界。因此我们就需要将对限界上下文的关注转移到对控制边界的理解。显然对应于统一语言限界上下文是语言的边界对于领域模型限界上下文是模型的边界二者可以帮助我们界定问题域Problem Space。对于系统的架构限界上下文确定了应用边界和技术边界进而帮助我们确定整个系统及各个限界上下文的解决方案。可以说限界上下文是连接问题域与解决方案域的重要桥梁。
下面将分别针对业务边界、工作边界与应用边界来深入探讨限界上下文的这种控制力。
限界上下文分离了业务边界
限界上下文首先分离了业务边界,用以约束不同上下文的领域模型。这种对领域模型的划分符合架构设计的基本原则,即从更加宏观和抽象的层次去分析问题域,如此既可以避免分析者迷失在纷繁复杂的业务细节知识中,又可以保证领域概念在自己的上下文中的一致性与完整性。
例如,在电商系统中,主要的产品实体 Product 在不同的限界上下文具有不同的含义,关注的属性与行为也不尽相同。在采购上下文,需要关注产品的进价、最小起订量与供货周期;在市场上下文中,则关心产品的品质、售价,以及用于促销的精美图片和销售类型;在仓储上下文中,仓库工作人员更关心产品放在仓库的哪个位置,产品的重量与体积,是否易碎品以及订购产品的数量;在推荐上下文中,系统关注的是产品的类别、销量、收藏数、正面评价数、负面评价数。
对于这种情况,我们不应该将这一概念建模为单个类,否则就可能导致不同限界上下文对应的领域模型为了代码重用,而共享这个共同的 Product 类,导致限界上下文之间产生代码的耦合,随之而来的,与领域模型相对应的数据模型也要产生耦合,如下图所示:
产品Product实体的设计也违背了“单一职责原则SRP它包含了太多本应分离的职责适用于不同的上下文从而变成了一个臃肿的上帝类
public class Product {
private Identity id;
private String name;
private Category category;
private Preriod leadTime;
private int minimumOrderQuant;
private Weight weight;
private Volumn volumn;
private int quantity;
private long annualSales;
private long favoritePoints;
private long positiveComments;
private long negetiveComments;
public Price priceFor(CustomerType customerType) {}
public PurchaseOrder buyFrom(Supplier supplier) {}
public Location allocate() {}
public boolean isFragile() {}
public Image[] loadImagesFrom(String filePath) {}
public Recommendations similar() {}
}
如果我们将产品看做是参与业务场景的角色进而在不同场景中考虑对象之间的协作那么是否可以遵循接口隔离原则ISP对 Product 实体类进行抽象呢?例如,在不同的限界上下文(作为 Product 的调用者)中,确定 Product 类扮演的不同角色,然后基于面向接口设计的原则为其定义多个细粒度的接口,如 Allocation 接口、Recommendation 接口、ImageLoader 接口等。这样的接口即 Martin Fowler 提出的角色接口Role Interface然后再让定义的 Product 类去实现这多个接口,体现了“大对象小角色”的设计思路。
如果只考虑设计层面,这样基于接口隔离原则进行设计的方案是合理的。例如,我们可以在各自的限界上下文中定义这些接口,然而,实现了这些接口的 Product 类又应该放在哪里?譬如说,我们可以引入一个产品上下文,然后在其内部定义 Product 类去实现这些接口。这样的设计是不合理的,它导致了产品上下文同时依赖其余四个限界上下文,形成了架构层面上限界上下文之间不必要的耦合,如下所示:
引入的限界上下文对设计产生了影响。在考虑设计方案时,我们需要时刻警醒限界上下文边界的控制力。限界上下文内部的协作成本要远远低于限界上下文之间的协作成本。在面向对象设计中,行之有效的“接口隔离原则”如果跨越了多个限界上下文,就变得不合理了。为了避免重复,我们引入了耦合,这种设计上的顾此失彼是不可取的。要降低耦合同时又能避免重复,更好的解决方案是让每一个限界上下文拥有自己的领域模型,该领域模型仅仅满足符合当前上下文需要的产品唯一表示。这其实是领域驱动设计引入限界上下文的主要目的:
虽然不同的限界上下文都存在相同的 Product 领域模型,但由于有了限界上下文作为边界,使得我们在理解领域模型时,是基于当前所在的上下文作为概念语境的。这样的设计既保证了限界上下文之间的松散耦合,又能够维持限界上下文各自领域模型的一致性,此时的限界上下文成为了保障领域模型不受污染的边界屏障。
限界上下文明确了工作边界
一个理想的开发团队规模最好能符合亚马逊公司提出的“Two-Pizza Teams”即 2PTs 规则,该规则认为“让团队保持在两个披萨能让成员吃饱的小规模”,大体而言,就是将团队成员人数控制在 7~10 人左右。为何要保证这样的规模呢?因为小团队能够更有效保证有效的沟通,如下图所示:
2PTs 规则自有其科学依据。如果我们将人与人之间的沟通视为一个“联结link则联结的数量遵守如下公式其中 n 为团队的人数:
[Math Processing Error]N(link)=n(n1)2
联结的数量直接决定了沟通的成本,以 6 人团队来计算,联结的数量为 15。如果在原有六人团队的规模上翻倍则联结数陡增至 66。对于传统项目管理而言一个 50 人的团队其实是一个小型团队,根据该公式计算得出的联结数竟然达到了惊人的 1225。如下图所示我们可以看到随着团队规模的扩大联结数的增长以远超线性增长的速度发展因而沟通的成本也将随之发生颠覆性的改变
随着沟通成本的增加团队的适应性也会下降。Jim Highsmith 在 Adaptive Software Development 一书中写道:
最佳的单节点(你可以想象成是通信网络中可以唯一定位的人或群体)联结数是一个比较小的值,它不太容易受网络规模的影响。即使网络变大,节点数量增加,每个节点所拥有的联结数量也一定保持着相对稳定的状态。
要做到人数增加不影响到联结数,就是要找到这个节点网络中的最佳沟通数量,也即前面提到的 2PTs 原则。然而团队规模并非解决问题的唯一办法如果在划分团队权责时出现问题则团队成员的数量不过是一种组织行为的表象罢了。如果结合领域驱动设计的需求则我们应该考虑在保持团队规模足够小的前提下按照软件的特性Feature而非组件Component来组织软件开发团队这就是所谓“特性团队”与“组件团队”之分。
传统的“组件团队”强调的是专业技能与功能重用,例如,熟练掌握数据库开发技能的成员组建一个数据库团队,深谙前端框架的成员组建一个前端开发团队。这种遵循“专业的事情交给专业的人去做”原则的团队组建模式,可以更好地发挥每个人的技能特长,然而牺牲的却是团队成员业务知识的缺失,客户价值的漠视。这种团队组建模式也加大了团队之间的沟通成本,导致系统的整体功能无法持续和频繁的集成。例如,由于业务变更需要针对该业务特性修改用户描述的一个字段,就需要从数据存储开始考虑到业务模块、服务功能,最后到前端设计。一个小小的修改就需要横跨多个组件团队,这种交流的浪费是多么不必要啊。在交流过程中,倘若还出现了知识流失,或者沟通不到位导致修改没有实现同步,就会带来潜在的缺陷。这种缺陷非常难以发现,即使在高覆盖率的集成测试下暴露了,缺陷定位、问题修复又是一大堆破事儿,需要协调多个团队。邮件沟通、电话沟通、你来我往、扯皮推诿,几天的时光如白驹过隙、转眼就过,问题还未必得到最终的解决。倘若这样的组件团队还是不同供应商的外包团队,分处于不同城市,可以想象这样的场景是多么“美好”!很“幸运”,我在参与某汽车制造商的零售商管理系统时,作为 CRM 模块的负责人,就摊上了这样的破事儿,如今思之,仍然不寒而栗啊!
为了规避这些问题,组建特性团队更有必要。所谓“特性团队”,就是一个端对端的开发垂直细分领域的跨职能团队,它将需求分析、架构设计、开发测试等多个角色糅合在一起,专注于领域逻辑,实现该领域特性的完整的端对端开发。一个典型的由多个特性团队组成的大型开发团队如下图所示:
如上图所示,我们按照领域特性来组建团队,使得团队成员之间的沟通更加顺畅,至少针对一个领域而言,知识在整个特性团队都是共享的。当然,我们在上图中也看到了组件团队的存在。这是因为在许多复杂软件系统中,毕竟存在一些具有相当门槛的专有功能,需要具有有专门知识或能够应对技术复杂度的团队成员去解决那些公共型的基础型的问题。二者的结合可以取长补短,但应以组建特性团队为主。
特性团队专注的领域特性是与领域驱动设计中限界上下文对应的领域是相对应的。当我们确定了限界上下文时其实也就等同于确定了特性团队的工作边界确定了限界上下文之间的关系也就意味着确定了特性团队之间的合作模式反之亦然。之所以如此则是因为康威定律Conways Law为我们提供了理论支持。
康威定律认为:“任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。” 在康威定律中起到关键杠杆作用的是沟通成本。如果同一个限界上下文的工作交给了两个不同的团队分工完成,为了合力解决问题,就必然需要这两个团队进行密切的沟通。然而,团队间的沟通成本显然要高于团队内的沟通成本,为了降低日趋增高的成本,就需要重新划分团队。反过来,如果让同一个团队分头做两个限界上下文的工作,则会因为工作的弱相关性带来自然而然的团队隔离。
如上图所示,我们可以设想这样一种场景,如果有两个限界上下文的工作,分配给两个不同的团队。分配工作时,却没有按照限界上下文的边界去组建团队,即每个团队会同时承担两个限界上下文的工作。试想,这会造成多少不必要的沟通成本浪费?借用 ORMObject Relational Mapping对象关系映射的概念我将这种职责分配的错位称之为“限界上下文与团队的阻抗不匹配”。如果能够将团队与限界上下文重合就能够降低沟通成本打造高效的领域特性团队专注于属于自己的限界上下文开发。

View File

@ -0,0 +1,83 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
014 限界上下文的控制力(下)
限界上下文封装了应用边界
架构师在划分限界上下文时,不能只满足于业务边界的确立,还得从控制技术复杂度的角度来考虑技术实现,从而做出对系统质量属性的响应与承诺,这种技术因素影响限界上下文划分的例子可谓是不胜枚举。
高并发
一个外卖系统的订单业务与门店、支付等领域存在业务相关性,然而考虑外卖业务的特殊性,它往往会在某个特定的时间段比如中午 11 点到 13 点会达到订单量的高峰值。系统面临高并发压力,同时还需要快速地处理每一笔外卖订单,与电商系统的订单业务不同,外卖订单具有周期短的时效性,必须在规定较短的时间内走完从下订单、支付、门店接单到配送等整个流程。如果我们将订单业务从整个系统中剥离出来,作为一个单独的限界上下文对其进行设计,就可以从物理架构上保证它的独立性,在资源分配上做到高优先级地扩展,在针对领域进行设计时,尽可能地引入异步化与并行化,来提高服务的响应能力。
功能重用
对于一个面向企业雇员的国际报税系统,报税业务、旅游业务与 Visa 业务都需要账户功能的支撑。系统对用户的注册与登录有较为复杂的业务处理流程。对于一个新用户而言,系统会向客户企业的雇员发送邀请信,收到邀请信的用户只有通过了问题验证才能成为合法的注册用户,否则该用户的账户就会被锁定,称之为 Registration Locked。在用户使用期间若违背了系统要求的验证条件也可能会根据不同的条件锁定账户分别称之为 Soft Locked 和 Hard Locked。只有用户提供了可以证明其合法身份的材料其账户才能被解锁。
账户管理并非系统的核心领域,但与账户相关的业务逻辑却相对复杂。从功能重用的角度考虑,我们应该将账户管理作为一个单独的限界上下文,以满足不同核心领域对这一功能的重用,避免了重复开发和重复代码。
实时性
在电商系统中商品自然是核心而价格Price则是商品概念的一个重要属性。倘若仅仅从业务的角度考虑在进行领域建模时价格仅仅是一个普通的领域值对象可倘若该电商系统的商品数量达到数十亿种每天获取商品信息的调用量在峰值达到数亿乃至数百亿次时价格就不再是业务问题而变成了技术问题。对价格的每一次变更都需要及时同步真实地反馈给电商客户。
为了保证这种在高并发情况下的实时性,我们就需要专门针对价格领域提供特定的技术方案,例如,通过读写分离、引入 Redis 缓存、异步数据同步等设计方法。此时,价格领域将作为一个独立的限界上下文,形成自己与众不同的架构方案,同时,为价格限界上下文提供专门的资源,并在服务设计上保证无状态,从而满足快速扩容的架构约束。
第三方服务集成
一个电商系统需要支持多种常见的支付渠道,如微信支付、支付宝、中国银联以及各大主要银行的支付。买家在购买商品以及进行退货业务时,可以选择适合自己的支付渠道完成支付。电商系统需要与这些第三方支付系统进行集成。不同的支付系统公开的 API 并不相同,安全、加密以及支付流程对支付的要求也不相同。
在技术实现上一方面我们希望为支付服务的客户端提供完全统一的支付接口以保证调用上的便利性与一致性另一方面我们希望能解除第三方支付服务与电商系统内部模块之间的耦合避免引起“供应商锁定Vender Lock也能更好地应对第三方支付服务的变化。因此我们需要将这种集成划分为一个单独的限界上下文。
遗留系统
当我们在运用领域驱动设计对北美医疗内容管理系统提出的新需求进行设计与开发时,这个系统的已有功能已经运行了数年时间。我们的任务是在现有系统中增加一个全新的 Find & Replace 模块,其目的是为系统中的医疗内容提供针对医疗术语、药品以及药品成分的查询与替换。这个系统已经定义了自己的领域模型。这些领域模型与新增模块的领域有相似之处。但是,为了避免已有模型对新开发模块的影响,我们应该将这些已有功能视为具有技术债的遗留系统,并将该遗留系统整体视为一个限界上下文。
通过这个遗留系统限界上下文的边界保护,就可以避免我们在开发过程中陷入遗留系统庞大代码库的泥沼。由于新增需求与原有系统在业务上存在交叉功能,因而可能失去了部分代码的重用机会,却能让我们甩开遗留系统的束缚,放开双手运用领域驱动设计的思想建立自己的领域模型与架构。只有在需要调用遗留系统的时候,作为调用者站在遗留系统限界上下文之外,去思考我们需要的服务,然后酌情地考虑模型对象之间的转换以及服务接口的提取。
如上的诸多案例都是从技术层面而非业务层面为系统划分了应用边界,这种边界是由限界上下文完成的,通过它形成了对技术实现的隔离,避免不同的技术方案选择互相干扰导致架构的混乱。
案例:生成税务报告的技术风险
国际税务系统需要在政府指定的周期提交税务报告,凡是满足条件的 Assignee 都需要在规定时间内生成税务报告。在生成税务报告时,需要对 Assignee 提交的 Questionnaire 数据进行合并,并基于税收策略与 Assignee 个人情况执行计算。生成税务报告的时序图如下所示:
代码如下所示:
public class TaxReportGenerator {
@Service
private HtmlReportProvider provider;
@Service
private PdfConverter converter;
@Repository
private ReportRepository repository;
public void generateReports(String calendarReportName) {
Byte[] bytes = provider.getHtmlBytes(calendarReportName);
Byte[] pdfBytes = converter.getPdfBytes(bytes, provider.getTitle());
repository.save(new TaxReport(pdfBytes));
}
}
由于每个 Assignee 的报告内容多,生成的 PDF 文件较大,使得生成税务报告的单位时间也较长。在最初用户量较少的情况下,所有税务报告的生成时间在客户预期范围内,因而并未针对报告生成功能做特别的架构设计。后来,随着系统的 Assignee 用户数增多,在提交税务报告的高峰期时,报告生成的时间越来越长。以高峰期需要提交 2000 个税务报告为例,如果每个税务报告的提交时间为 1 分钟,在只有一个 worker 的情况下我们需要2000*1/60=33小时。
由于单个税务报告的生成性能已经达到瓶颈,没有优化的空间,因而需要在架构层面对方案进行优化,包括如下两方面:
引入消息队列,将整个税务报告生成过程拆分为消息队列的生产者和消费者。处于应用服务器一端的生产者仅负责收集税务报告需要的数据,而将生成报告的职责交给消息队列的消费者,从而减轻应用服务器的压力。
将报告生成识别为限界上下文,定义为可以单独部署的微服务,以便于灵活地实现水平扩展。
如下图是我们基于技术实现识别出来的 report 限界上下文。在上下文边界内引入了消息队列。server 作为生成者,在收集了税务数据后组装消息,然后将消息入队;作为消费者的 worker 订阅该消息,一旦消息传递到达,则负责生成报告:
无论是 server 还是 worker皆为并行执行且在理论上可以无限制地水平扩展。倘若在性能上无法满足要求我们可以增加 server 或 worker 节点。例如,我们希望所有税务报告能够在 4 小时内处理完毕通过公式2000*1/60/4计算预估需要 7 个 worker 并行执行即可满足目标。

View File

@ -0,0 +1,94 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
015 识别限界上下文(上)
不少领域驱动设计的专家都非常重视限界上下文。Mike 在文章《DDD: The Bounded Context Explained》中写道“限界上下文是领域驱动设计中最难解释的原则但或许也是最重要的原则可以说没有限界上下文就不能做领域驱动设计。在了解聚合根Aggregate Root、聚合Aggregate、实体Entity等概念之前需要先了解限界上下文。”然而现实却是很少有文章或著作专题讲解该如何识别限界上下文。
我曾经向《实现领域驱动设计》的作者 Vaughn Vernon 请教如何在领域驱动设计中识别出正确的限界上下文他思索了一会儿回答我“By experience.(凭经验)”,这是一个机智的回答,答案没有错,可是也没有任何借鉴意义,等于说了一句正确的废话。
在软件开发和设计领域,任何技能都是需要凭借经验积累而逐步提升的。然而作为一种设计方法,领域驱动设计强调了限界上下文的重要性,却没有提出一个值得参考并作为指引的过程方法,这是不负责任的。
Andy Hunt 在《程序员的思维修炼》这本书中分析了德雷福斯模型的 5 个阶段新手、高级新手、胜任者、精通者和专家。对于最高阶段的“专家”Andy Hunt 得到一个有趣的结论“专家根据直觉工作Experts work from intuition而不需要理由。”这似乎充满了神秘主义然而这种专家的直觉实际上是通过不断的项目实践千锤百炼出来的也可以认为是经验的累积。经验的累积过程需要方法否则所谓数年经验不过是相同的经验重复多次罢了没有价值。Andy Hunt 认为需要给新手提供某种形式的规则去参照,之后,高级新手会逐渐形成一些总体原则,然后通过系统思考和自我纠正,建立或者遵循一套体系方法,就能从高级新手慢慢成长为胜任者、精通者。因此,从新手到专家是一个量变引起质变的过程,在没有能够养成直觉的经验之前,我们需要有一套方法。
我在一些项目中尝试着结合了诸多需求分析方法与设计原则,慢慢摸索出了属于自己的一套体系。归根结底,限界上下文就是“边界”,这与面向对象设计中的职责分配其实是同一道理。限界上下文的识别并不是一蹴而就的,需要演化和迭代,结合着我对限界上下文的理解,我认为通过从业务边界到工作边界再到应用边界这三个层次抽丝剥茧,分别以不同的视角、不同的角色协作来运用对应的设计原则,会是一个可行的识别限界上下文的过程方法。当然,这个过程相对过重,如果仅以此作为输出限界上下文的方法,未免有些得不偿失。需要说明的是,这个过程除了能够帮助我们更加准确地识别限界上下文之外,还可以帮助我们分析需求、识别风险、确定架构方案。整体过程如下图所示:
从业务边界识别限界上下文
领域驱动设计围绕着“领域”来开展软件设计。在明确了系统的问题域和业务期望后开发团队与领域专家经过充分地沟通与交流可以梳理出主要的业务流程这些业务流程体现了各种参与者在这个过程中通过业务活动共同协作最终完成具有业务价值的领域功能。显然业务流程结合了参与角色Who、业务活动What和业务价值Why。在业务流程的基础上我们就可以抽象出不同的业务场景这些业务场景又由多个业务活动组成我们可以利用前面提到的领域场景分析方法剖析场景以帮助我们识别业务活动例如采用用例对场景进行分析此时一个业务活动实则就是一个用例。
例如,在针对一款文学阅读产品进行需求分析时,我们得到的业务流程为:
登录读者根据作品名或者作者名查询自己感兴趣的作品。在找到自己希望阅读的作品后,开始阅读。若阅读的作品为长篇,可以按照章节阅读,倘若作品为收费作品,则读者需要支付相应的费用,支付成功后可以阅读购买后的作品。在阅读时,倘若读者看到自己喜欢的句子或段落,可以作标记,也可以撰写读书笔记,还可以将自己喜欢的内容分享给别的朋友。读者可以对该作品和作者发表评论,关注自己喜欢的作品和作者。
注册用户可以申请成为驻站作者。审核通过的作者可以在创作平台上发布自己的作品,发布作品时,可以根据需要设置作品的章节。作者可以在发布作品之前预览作品,无论作品是否已经发布,都可以对作品的内容进行修改。作者可以设置自己的作品为收费或免费作品,并自行确定阅读作品所需的费用。如果是新作品发布,系统会发送消息通知该作者的关注者;若连载作品有新章节发布,系统会发送消息通知该作品的关注者。
驻站作者可以为自己的作品建立作品读者群,读者可以申请加入该群,加入群的读者与作者可以在线实时聊天,也可以发送离线信息,或者将自己希望分享的内容发布到读者群中。注册用户之间可以发起一对一的私聊,也可以直接给注册用户发送私信。
通过对以上业务流程进行分析,结合在各个流程环节中需要的知识以及参与角色的不同,可以划分如下业务场景:
阅读作品
创作作品
支付
社交
消息通知
注册与登录
可以看到,业务流程是一个由多个用户角色参与的动态过程,而业务场景则是这些用户角色执行业务活动的静态上下文。从业务流程中抽象出来的业务场景可能是交叉重叠的,例如在读者阅读作品流程与作者创作流程中,都牵涉到支付场景的相关业务。
接下来我们利用领域场景分析的用例分析方法剖析这些场景。我们往往通过参与者Actor来驱动对用例的识别这些参与者恰好就是参与到场景业务活动的角色。根据用例描述出来的业务活动应该与统一语言一致最好直接从统一语言中撷取。业务活动的描述应该精准地表达领域概念且通过尽可能简洁的方式进行描述通常格式为动宾形式。以阅读作品场景为例可以包括如下业务活动
查询作品
收藏作品
关注作者
浏览作品目录
阅读作品
标记作品内容
撰写读书笔记
评价作品
评价作者
分享选中的作品内容
分享作品链接
购买作品
一旦准确地用统一语言描述出这些业务活动,我们就可以从如下两个方面识别业务边界,进而提炼出初步的限界上下文:
语义相关性
功能相关性
语义相关性
从语义角度去分析业务活动的描述,倘若是相同的语义,可以作为归类的特征。语义相关性主要来自于描述业务活动的宾语。例如,前述业务活动中的查询作品、收藏作品、分享作品、阅读作品都具有“作品”的语义,基于这一特征,我们可以考虑将这些业务活动归为同一类。
识别语义相关性的前提是准确地使用统一语言描述业务活动。在描述时应尽量避免使用“管理manage”或“维护maintain”等过于抽象的词语。抽象的词语容易让我们忽视隐藏的领域语言缺少对领域的精确表达。例如在文学阅读产品中我们不能宽泛地写出“管理作品”、“管理作者”、“维护支付信息”等业务活动而应该挖掘业务含义只有如此才能得到诸如收藏作品、撰写作品、发布作品、设置作品收费模式、查询支付流水、对账等符合领域知识的描述。当然这里也有一个业务活动层次的问题。在进行业务分析时若我们发现只能使用“管理”或“维护”之类的抽象字眼来表述该用户活动时则说明我们选定的用户活动层次过高应该继续细化。细化后的业务活动既能更好地表达领域知识又能让我们更好地按照语义相关性去寻找业务的边界可谓一举两得。
在进行语义相关性判断时,还需要注意业务活动之间可能存在不同的语义相关性。例如,在文学阅读产品中,查询作品、阅读作品与撰写作品具有“作品”的语义相关,而评价作品与评价作者又具有“评价”的语义相关,究竟应该以哪个语义为准呢?没有标准!我们只能按照相关性的耦合程度进行判断。如果我们将评价视为一个相对独立的限界上下文,则评价作品与评价作者放入评价上下文会更好。
功能相关性
从功能角度去分析业务活动是否彼此关联和依赖,倘若存在关联和依赖,可以作为归类的特征,这种关联性,代表了功能之间的相关性。倘若两个功能必须同时存在,又或者缺少一个功能,另一个功能是不完整的,则二者就是功能强相关的。通常,这种功能相关性极具有欺骗性,因为系统总是包含这样那样彼此依赖的功能。要判断这种依赖关系的强弱,并不比分析人与人之间的关系简单。倘若我们运用用例分析方法,就可以通过用例之间的关系来判别功能相关性,如用例的包含与扩展关系,其中包含关系展现了功能的强相关性。所谓“功能相关性”,指的就是职责的内聚性,强相关就等于高内聚。故而从这个角度看,功能相关性的判断标准恰好符合“高内聚、松耦合”的设计原则。
仍然以前面提到的文学阅读产品为例。发布作品与验证作品内容是功能相关的,且属于用例的包含关系,因为如果没有对发布的作品内容进行验证,就不允许发布作品。对于这种强相关的功能,我们通常都会考虑将其归入到同一个限界上下文。又例如发布作品与设置作品收费模式是功能相关的,但并非强相关,因为设置作品收费模式并非发布作品的前置约束条件,属于用例中的扩展关系。但由于二者还存在语义相关性,因而将其放入到同一个限界上下文中也是合理的。
两个相关的功能未必一定属于同一个限界上下文。例如,购买作品与支付购买费用是功能相关的,且前者依赖于后者,但后者从领域知识的角度判断,却应该分配给支付上下文,我们非但不能将其紧耦合在一起,还应该竭尽所能降低二者之间的耦合度。因此,我在识别限界上下文时,仅仅将“功能相关性”作为一种可行的参考,它并不可靠,却能给你一些提醒。事实上,功能相关性往往会与上下文之间的协作关系有关。由于这种功能相关性恰恰对应了用例之间的包含与扩展关系,它们往往又可成为识别限界上下文边界的关键点。我在后面讲解上下文映射时还会详细阐释。
为业务边界命名
无论是语义相关性还是功能相关性,都是分类业务活动的一种判断标准。一旦我们将识别出来的业务活动进行归类,就自然而然地为它们划定了业务边界,接下来,我们需要对划定的业务边界进行命名,这个命名的过程其实就是识别所有业务活动共同特征,并以最准确地名词来表达该特征。倘若我们划分的业务活动欠妥当,对这个业务边界命名就会成为一种巨大的挑战。例如,我们从建立读者群、加入读者群,发布群内消息、实时聊天、发送离线消息、一对一私聊与发送私信等业务活动找到“社交”的共同特征,因而得到社交上下文。但如果我们将阅读作品、收藏作品与关注作者、查看作者信息放在一个业务边界内,命名就变得有些棘手了,我们总不可能称呼其为“作品与作者”上下文吧!因此,对业务边界的命名可以算作是对限界上下文识别的一种检验手段。
整体而言,从业务边界识别上下文的重点在于“领域”。若理解领域逻辑有误,就可能影响限界上下文的识别。因此,这个阶段需要开发团队与领域专家紧密合作,这个阶段也将是一个充分讨论和分析的过程。它是一个迭代的过程。很多时候,如果我们没有真正去实现这些限界上下文,我们有可能没有完全正确地理解它。当我们距离真正理解业务还有距离的时候,不妨先“草率”地规划它,待到一切都明朗起来,再寻机重构。

View File

@ -0,0 +1,107 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
016 识别限界上下文(下)
从工作边界识别限界上下文
正如架构设计需要多个视图来全方位体现架构的诸多要素,我们也应借助更多的角度全方位分析限界上下文。如果说为限界上下文划分业务边界,更多的是从业务相关性(内聚)判断业务的归属,那么基于团队合作划分工作边界可以帮助我们确定限界上下文合理的工作粒度。
倘若我们认可第 3-2 课中提及的三个原则或实践2PTs 规则、特性团队、康威定律,则意味着项目经理需要将一个限界上下文要做的工作分配给大约 7~10 人的特性团队。如此看来,对限界上下文的粒度识别就变成了对工作量的估算。我们并没有严谨的算法去准确估算工作量,可是对于一个有经验的项目经理(或者技术负责人),要进行工作量的大致估算,还是能够办到的。当我们发现一个限界上下文过大,又或者特性团队的工作分配不均匀时,就应该果断对已有限界上下文进行切分。
工作分配的基础在于“尽可能降低沟通成本”,遵循康威定律,沟通其实就是项目模块之间的依赖,这个过程同样不是一蹴而就的。康威认为:
在大多数情况下,最先产生的设计都不是最完美的,主导的系统设计理念可能需要更改。因此,组织的灵活性对于有效的设计有着举足轻重的作用,必须找到可以鼓励设计经理保持他们的组织精简与灵活的方法。
特性团队正是用来解决这一问题的。换言之,当我们发现团队规模越来越大,失去了组织精简与灵活的优势,实际上就是在传递限界上下文过大的信号。项目经理对此需要有清醒认识,当团队规模违背了 2PTs 时,就该坐下来讨论一下如何细分团队的问题了。因此,按照团队合作的角度划分限界上下文,其实是一个动态的过程、演进的过程。
我在给某音乐网站进行领域驱动设计时,通过识别业务相关性划分了如下限界上下文。
Media Playeronline & offline提供音频和视频文件的播放功能区分在线播放与离线播放
Music与音乐相关的业务包括乐库、歌单、歌词
FM Radio电台
Live直播
MV短视频和 MV
Singer歌手
Musician音乐人注意音乐人与歌手的区别
Music Community音乐社区
File Sharing包括下载和传歌等与文件有关的功能
Tag支持标签管理包括音乐的分类如最新、话题等分类标签还有歌曲标签
Loyalty与提高用户粘度有关的功能如关注、投票、收藏、歌单等功能
Utilities音乐工具包括音效增强等功能
Recommendation推荐
Search对整个音乐网站内容的搜索包括对人、歌曲、视频等内容的搜索
Activity音乐网站组织的活动
Advertisement推广与广告
Payment支付。
在识别限界上下文时我将直播Live视为与音乐、电台、MV 短视频同等层次的业务分类,然而,殊不知该音乐网站直播模块的开发团队已经随着功能的逐渐增强发展到了接近 200 人规模的大团队,这显然不是一个限界上下文边界可以控制的规模。即使属于直播业务的业务活动都与直播领域知识有关,我们也应该基于 2PTs 原则对直播限界上下文作进一步分解,以满足团队管理以及团队成员充分沟通的需要。
如果我们从团队合作层面看待限界上下文就从技术范畴上升到了管理范畴。Jurgen Appelo 在《管理 3.0培养和提升敏捷领导力Management 3.0: Leading Agile DevelopersDeveloping Agile Leaders》这本书中提到一个高效的团队需要满足两点要求
共同的目标
团队的边界
虽然 Jurgen Appelo 在提及边界时,是站在团队结构的角度来分析的;可在设计团队组织时确定工作边界的原则,恰恰与限界上下文的控制边界暗暗相合。总结书中对边界的阐释,大致包括:
团队成员应对团队的边界形成共识,这就意味着团队成员需要了解自己负责的限界上下文边界,以及该限界上下文如何与外部的资源以及其他限界上下文进行通信。
团队的边界不能太封闭(拒绝外部输入),也不能太开放(失去内聚力),即所谓的“渗透性边界”,这种渗透性边界恰恰与“高内聚、松耦合”的设计原则完全契合。
针对这种“渗透性边界”,团队成员需要对自己负责开发的需求“抱有成见”,在识别限界上下文时,“任劳任怨”的好员工并不是真正的好员工。一个好的员工明确地知道团队的职责边界,他应该学会勇于承担属于团队边界内的需求开发任务,也要敢于推辞职责范围之外强加于他的需求。通过团队每个人的主观能动,就可以渐渐地形成在组织结构上的“自治单元”,进而催生出架构设计上的“自治单元”。同理,“任劳任怨”的好团队也不是真正的好团队,团队对自己的边界已经达成了共识,为什么还要违背这个共识去承接不属于自己边界内的工作呢?这并非团队之间的“恶性竞争”,也不是工作上的互相推诿;恰恰相反,这实际上是一种良好的合作,表面上维持了自己的利益,然而在一个组织下,如果每个团队都以这种方式维持自我利益,反而会形成一种“互利主义”。
这种“你给我搔背,我也替你抓抓痒”的互利主义最终会形成团队之间的良好协作。如果团队领导者与团队成员能够充分认识到这一点,就可以从团队层面思考限界上下文。此时,限界上下文就不仅仅是架构师局限于一孔之见去完成甄别,而是每个团队成员自发组织的内在驱动力。当每个人都在思考这项工作该不该我做时,变相地就是在思考职责的分配是否合理,限界上下文的划分是否合理。
从应用边界识别限界上下文
质量属性
管理的目的在于打造高效的团队但最后还是要落脚到技术实现上来不懂业务分析的架构师不是一个好的程序员而一个不懂得提前识别系统风险的程序员更不是一个好的架构师。站在技术层面上看待限界上下文我们需要关注的其实是质量属性Quality Attributes。如果把关乎质量属性的问题都视为在将来可能会发生其实就是“风险Risk”。
架构是什么Martin Fowler 认为:架构是重要的东西,是不容易改变的决策。如果我们未曾预测到系统存在的风险,不幸它又发生了,带给系统架构的改变可能是灾难性的。利用限界上下文的边界,就可以将这种风险带来的影响控制在一个极小的范围,这也是前面提及的安全。为什么说限界上下文是领域驱动设计中最重要的元素,答案就在这里。
我曾经负责开发一款基于大数据平台的 BI 产品,在架构设计时,对性能的评估方案是存在问题的,我们当时考虑了符合生产规模的数据量,并以一个相对可行的硬件与网络环境,对 Spark + Parquet 的技术选型进行测试测试结果满足了设定的响应时间值。然而两个因素的缺失为我们的架构埋下了祸根。在测试时我们没有考虑并发访问量测试的业务场景也过于简单。我们怀着一种鸵鸟心态在理论上分析这种决策Spark 是当时最快速的基于内存的数据分析平台Parquet 是列式存储,尤为适合统计分析)是对的,然后就按照我们期望的形式去测试,实际上是将风险悄悄地埋藏起来。
当产品真正销售给客户使用时,我们才发现客户的业务场景非常复杂,对性能的要求也更加苛刻。例如,它要求达到 100 ~ 500 的并发访问量,同时对大数据量进行统计分析与指标运算,并期望实时获得分析结果;而客户所能提供的 Spark 集群却是有限度的。事实上,基于 Spark 的 driver-worker 架构它本身并不擅长完成高并发的数据分析任务。对于一个分析任务Spark 可以利用集群的力量由多个 worker 同时并行地执行成百上千的 task但瓶颈在 driver 端,一旦上游同时有多个请求涌入,响应能力就不足了。最终,我们的产品在真正的压力测试下一败涂地。
幸而,我们划定了限界上下文,并由此建立了数据分析微服务。针对客户高并发的实时统计分析需求,在保证 REST API 不变的情况下,我们更改了技术选型,选择基于 ElasticSearch 的数据分析微服务替换旧服务。这种改变几乎不影响产品的其他模块与功能前端代码仅仅做了少量修改。3 个人的团队在近一个月的周期内基本完成了这部分数据分析功能,及时掐断了炸药的导火线。
重用和变化
无论是重用领域逻辑还是技术实现都是在设计层面上我们必须考虑的因素需求变化更是影响设计策略的关键因素。我在前面分析限界上下文的本质时就提及一个限界上下文其实是一个“自治”的单元。基于自治的四个特征我们也可以认为这个自治的单元其实就是逻辑重用和封装变化的设计单元。这时对限界上下文边界的考虑更多是出于技术设计因素而非业务因素。在后面讲解的上下文映射Context Map模式时Eric Evans 总结的共享内核其实就是重用的体现,而开放主机服务与防腐层则是对变化的主动/被动应对。
运用重用原则分离出来的限界上下文往往对应于子领域Sub Domain尤其作为支撑子领域。我在为一家公司的物流联运管理系统提供领域驱动设计咨询时通过与领域专家的沟通我注意到他在描述运输、货站以及堆场的相关业务时都提到了作业和指令的概念。虽然属于不同的领域但指令的收发、作业的制订与调度都是相同的区别只在于作业与指令的内容以及作业调度的周期。为了避免在运输、货站与堆场各自的限界上下文中重复设计与实现作业与指令等领域模型我们可以将作业与指令单独划分到一个专门的限界上下文中。它作为上游限界上下文提供对运输、货站与堆场的业务支撑。
限界上下文对变化的应对,其实是“单一职责原则”的体现,即一个限界上下文不应该存在两个引起它变化的原因。还是这个物流联运管理系统,最初团队的设计人员将运费计算与账目、结账等功能放在了财务上下文中。当国家的企业征税策略发生变化时,会引起财务上下文的变化,引起变化的原因是财务规则与政策的调整。倘若运费计算的规则也发生了变化,同样会引起财务上下文的变化,但引起变化的原因却是物流运输的业务需求。如果我们将运费计算单独从财务上下文中分离出来,就可以独立演化,符合前面提及的“自治”原则,实现了两种不同关注点的分离。
遗留系统
自治原则的唯一例外是遗留系统,因为领域驱动设计建议的通常做法是将整个遗留系统视为一个限界上下文。那么,什么是遗留系统?根据维基百科的定义,它是一种旧的方法、旧的技术、旧的计算机系统或应用程序,这个定义并不能解释遗留系统的真相。我认为,系统之所以成为遗留系统,关键在于知识的缺乏。文档不够全面真实,掌握系统知识的团队成员泰半离开,系统的代码可能是一个大泥团。因此,我对遗留系统的定义是“一个还在运行和使用,但已步入软件生命衰老期的缺乏足够知识的软件系统”。
倘若运用领域驱动设计的系统要与这样一个遗留系统打交道,应该怎么办?窃以为,粗暴地将整个遗留系统包裹在一个限界上下文中,未免太理想化和简单化了。要点还是自治,这时候我们应该站在遗留系统的调用者来观察它,考虑如何与遗留系统集成,然后逐步对遗留系统进行抽取与迁移,形成自治的限界上下文。
在这个过程中我们可以借鉴技术栈迁移中常常运用的“抽象分支Branch By Abstraction”手法。该手法会站在消费者Consumer一方观察遗留系统找到需要替换的单元组件然后对该组件进行抽象从而将消费者与遗留系统中的实现解耦。最后提供一个完全新的组件实现在保留抽象层接口不变的情况下替换掉遗留系统的旧组件达到技术栈迁移的目的
如上图所示的抽象层本质就是后面我们要提到的“防腐层Anticorruption Layer通过引入这么一个间接层来隔离与遗留系统之间的耦合。这个防腐层往往是作为下游限界上下文的一部分存在。若有必要也可以单独为其创建一个独立的限界上下文。
设计驱动力
结合业务边界、工作边界和应用边界,形成一种层层推进的设计驱动力,可以让我们对限界上下文的设计变得更加准确,边界的控制变得更加合理,毕竟,限界上下文的识别对于整个系统的架构至关重要。在领域驱动的战略设计阶段,如果我们对识别出来的限界上下文的准确性还心存疑虑,那么比较实际的做法是保持限界上下文一定的粗粒度。倘若觉得功能的边界不好把握分寸,可以考虑将这些模棱两可的功能放在同一个限界上下文中。待到该限界上下文变得越来越庞大,以至于一个 2PTs 团队无法完成交付目标;又或者该限界上下文的功能各有不同的质量属性要求;要么就是因为重用或变化,使得我们能够更清楚地看到分解的必要性;此时我们再对该限界上下文进行分解,就会更加有把握。这是设计的实证主义态度。
通过以上过程去识别限界上下文仅仅是一种对领域问题域的静态划分我们还缺少另外一个重要的关注点限界上下文之间是如何协作的倘若限界上下文识别不合理协作就会变得更加困难尤其当一个限界上下文对应一个微服务时协作成本更会显著增加。反过来当我们发现彼此协作存在问题时说明限界上下文的划分出现了问题这算是对识别限界上下文的一种验证方法。Eric Evans 将这种体现限界上下文协作方式的要素称之为“上下文映射Context Map”。

View File

@ -0,0 +1,29 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
017 理解上下文映射
一个软件系统通常被分为多个限界上下文,这是运用“分而治之”思想来降低业务复杂度的有效手段,设计的难题往往会停留在“如何分”,然而限界上下文之间的“怎么合”问题同样值得关注,分与合遵循的还是软件设计的最高原则——高内聚、松耦合。分是合的基础,基于内聚相关度进行合理的分配,可以在一定程度减少限界上下文之间不必要的关联。假设分配是合理的,则接下来的“合”就是要尽可能地降低彼此之间的耦合。
既然前面提及限界上下文的识别是一个迭代过程,当我们在思考限界上下文该如何协作时,倘若发现协作总有不合理之处,就可能会是一个“设计坏味道”的信号,它告诉我们:之前识别的限界上下文或有不妥,由是可以审视之前的设计,进而演进为更为准确的限界上下文划分。即使抛开对设计的促进作用,思考限界上下文是如何协作的,仍然格外重要,我们既要小心翼翼地维护限界上下文的边界,又需要它们彼此之间良好的协作,并思考协作的具体实现方式,这个思考过程既牵涉到逻辑架构层面,又与物理架构有关,足以引起我们的重视。
领域驱动设计通过上下文映射Context Map 来讨论限界上下文之间的协作问题上下文映射是一种设计手段Eric Evans 总结了诸如共享内核Shared Kernel、防腐层Anticorruption Layer、开放主机服务Open Host Service等多种模式。由于上下文映射本质上是与限界上下文一脉相承的因此要掌握这些协作模式应该从限界上下文的角度进行理解着眼点还是在于“边界”。领域驱动设计认为上下文映射是用于将限界上下文边界变得更清晰的重要工具。所以当我们正在为一些限界上下文的边界划分而左右为难时不妨先放一放在定下初步的限界上下文后通过绘制上下文映射来检验或许会有意外收获。
限界上下文的一个核心价值,就是利用边界来约束不同上下文的领域模型,以保证模型的一致性。然而,每个限界上下文都不是独立存在的,多数时候,都需要多个限界上下文通力协作,才能完成一个完整的用例场景。例如,客户之于商品、商品之于订单、订单之于支付,贯穿起来才能完成“购买商品”的核心流程。
两个限界上下文之间的关系是有方向的领域驱动设计使用两个专门的术语来表述它们“上游Upstream”和“下游Downstream在上下文映射图中以 U 代表上游D 代表下游,理解它们之间的关系,正如理解该术语隐喻的河流,自然是上游产生的变化会影响到下游,反之则不然。故而从上游到下游的关系方向,代表了影响产生的作用力,影响作用力的方向与程序员惯常理解的依赖方向恰恰相反,上游影响了下游,意味着下游依赖于上游。
在划分限界上下文的业务边界时,我们常常从“语义相关性”与“功能相关性”两个角度去判别职责划分的合理性。在上下文映射中,我发现之所以两个业务边界的限界上下文能产生上下游协作关系,皆源于二者的功能相关性,这种功能相关存在主次之分,往往是上游限界上下文作为下游限界上下文的功能支撑,这就意味着在当前的协作关系下,下游限界上下文中的用例才是核心领域。例如,订单与支付,下订单用例才是核心功能,支付功能作为支撑的公开服务而被调用;例如,邮件与文件共享,写邮件用例才是核心功能,上传附件作为支撑的公开服务而被调用;例如,项目管理与通知,分配任务用例才是核心功能,通知功能作为支撑的公开服务而被调用。巧的是,这种主次功能的调用关系,几乎对应的就是用例图中的包含用例或扩展用例。
如果我们通过用例图来帮助识别限界上下文,那么,用例图中的包含用例或扩展用例或许是一个不错的判断上下文协作关系的切入点。选择从包含或扩展关系切入,既可能确定了职责分离的逻辑边界,又可以确定协作关系的方向,这就是用例对领域驱动设计的价值所在了。
那么如何将上下文映射运用到领域驱动的战略设计阶段Eric Evans 为我们总结了常用的上下文映射模式。为了更好地理解这些模式,结合限界上下文对边界的控制力,再根据这些模式的本质,我将这些上下文映射模式分为了两大类:团队协作模式与通信集成模式。前者对应的其实是团队合作的工作边界,后者则从应用边界的角度分析了限界上下文之间应该如何进行通信才能提升设计质量。针对通信集成模式,结合领域驱动设计社区的技术发展,在原有上下文映射模式基础上,增加了发布/订阅事件模式。

View File

@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
018 上下文映射的团队协作模式
如果我们将限界上下文理解为是对工作边界的控制,则上下文之间的协作实则就是团队之间的协作,高效的团队协作应遵循“各司其职、权责分明”的原则。从组织层面看,需要预防一个团队的“权力膨胀”,导致团队的“势力范围”扩大到整个组织;从团队层面,又需要避免自己的权力遭遇压缩,导致自己的话语权越来越小,这中间就存在一个平衡问题。映射到领域驱动设计的术语,就是要在满足合理分配职责的前提下,谨慎地确保每个限界上下文的粒度。
当然一个高效的组织其内部团队之间必然不是“老死不相往来”的陌生客。职责的合理分配可以更好地满足团队的自组织或者说自治但不可能做到“万事不求人”全靠自己来做。如果什么事情都由这一个团队完成这个团队也就成为无所不能的“上帝”团队了。Vaughn Vernon 就认为“上下文映射展现了一种组织动态能力Organizational Dynamic它可以帮助我们识别出有碍项目进展的一些管理问题。”这也是我为何要在识别上下文的过程中引入项目经理这个角色的原因所在因为在团队协作层面限界上下文与项目管理息息相关。
领域驱动设计根据团队协作的方式与紧密程度,定义了五种团队协作模式。
合作关系Partnership
合作Partnership是一个美好的词语但在软件设计中却未必是一个正面的褒义词因为合作得越多就意味着依赖越多。Vaughn Vernon 在其著作《实现领域驱动设计》中如此定义这种关系:
如果两个限界上下文的团队要么一起成功,要么一起失败,此时他们需要建立起一种合作关系,他们需要一起协调开发计划和集成管理。两个团队应该在接口的演化上进行合作以同时满足两个系统的需求。应该为相互关联的软件功能制定好计划表,这样可以确保这些功能在同一个发布中完成。
这种一起成功或一起失败的“同生共死”关系代表的固然是良好的合作,却也说明二者可能存在强耦合关系,甚至是糟糕的双向依赖。对于限界上下文的边界而言,即使是逻辑边界,出现双向依赖也是不可饶恕的错误。倘若我们视限界上下文为微服务,则这种“确保这些功能在同一个发布中完成”的要求,无疑抵消了许多微服务带来的好处,负面影响不言而喻。
在我过去参与的一个面向教育行业的 SaaS 系统中,我们划分了 ReportEngine、EntityEngine 与 DataEngine 以及 ReportDesigner 等限界上下文。当绘制出上下文映射图时,我们发现这多个限界上下文之间出现了双向依赖与循环依赖,如下图所示:
说明: 虽然在领域驱动设计中,我们应该以标准的模式来表示限界上下文之间的关系,例如标注 U 和 D 代表上游和下游,标注 Partnership 说明二者为合作关系。但在上图我却采用了依赖方式来说明,目的是可以更清晰地体现双向依赖和循环依赖的特征。
ReportEngine 与 EntityEngine 之间存在双向依赖,二者又与 DataEngine 之间产生了循环依赖。这种依赖导致三个限界上下文“貌离神合”,边界控制不够彻底,使得它们并不能真正的分开。倘若这三个限界上下文被构建为三个 JAR 包,这种依赖会导致它们在编译时谁也离不开谁。如果是微服务,则任何一个服务出现故障,其他服务都不可用。
我个人认为限界上下文的“合作关系”其实是一种“反模式”罪魁祸首是因为职责分配的不当是一种设计层面的“特性依恋Feature envy”坏味道。解决的办法通常有三种
既然限界上下文存在如此紧密的合作关系,就说明当初拆分的理由较为牵强,与其让它们因为分开而“难分难舍”,不如干脆让它们合在一起。
将产生特性依赖的职责分配到正确的位置,尽力减少一个方向的多余依赖。
识别产生双向依赖或循环依赖的原因然后将它们从各个限界上下文中剥离出来并为其建立单独的限界上下文这就是所谓的“共享内核Shared Kernel”。
分析前面的例子,之所以 ReportEngine、EntityEngine 与 DataEngine 之间存在不正确的循环依赖,原因是我们错误地将元数据功能放到了 ReportEngine 限界上下文中。EntityEngine 与DataEngine 之所以依赖 ReportEngine并不是需要调用属于 ReportEngine 本身职责的功能,而是需要访问元数据。事实上,我们还发现 ReportDesigner 也是因为需要访问元数据,才会依赖 ReportEngine。此时拆分出单独的元数据限界上下文才是最佳选择
新引入的 Metadata 成为了其余限界上下文的上游,却解除了 DataEngine 对 ReportEngine 的依赖,同样解除了 EntityEngine 以及 ReportDesigner 对 ReportEngine 的依赖。多余引入的 Metadata 上下文就是我们之前在识别上下文时未曾发现的,现在通过上下文映射,帮助我们甄别了这一错误,及时调整了系统的限界上下文。
共享内核Shared Kernel
前面提取“元数据限界上下文”的模式,就是“共享内核”的体现。从设计层面看,共享内核是解除不必要依赖实现重用的重要手段。当我们发现了属于共享内核的限界上下文后,需要确定它的团队归属。注意,共享内核仍然属于领域的一部分,它不是横切关注点,也不是公共的基础设施。分离出来的共享内核属于上游团队的职责,因而需要处理好它与下游团队的协作。
虽然名为“内核”但这是一种技术层面的命名并不一定意味着该限界上下文的逻辑属于核心领域Core Domain。相反多数情况下共享内核属于子领域SubDomain
共享内核往往被用来解决合作关系引入的问题。
共享内核是通过上下文映射识别出来的,通过它可以改进设计质量,弥补之前识别限界上下文的不足。与其说它是上下文映射的一种模式,不如说它是帮助我们识别隐藏限界上下文的模式,主要的驱动力就是“避免重复”,即 DRYDont Repeat Yourself原则的体现。在前面讲解通过应用边界识别限界上下文时我提到了物流联运管理系统。运输、货站以及堆场都用到了作业与指令功能。显然作业与指令功能放在运输、货站或堆场都不合理这时就是运用“共享内核”的时机。为了避免重复也为了避免不必要的依赖可以提取出作业上下文。
当然这种重用是需要付出代价的。Eric Evans 指出:“共享内核不能像其他设计部分那样自由更改,在做决定时需要与另一个团队协商。”至于修改产生的影响有多大,需要视该限界上下文与其他限界上下文之间的集成关系。尤其是大多数共享内核可能是多个限界上下文共同的上游,每次修改都可能牵一发而动全身。因此在对共享内核进行修改时,需要充分评估这种修改可能带来的影响。
客户方-供应方开发Customer-Supplier Development
正常情况下,这是团队合作中最为常见的合作模式,体现的是上游(供应方)与下游(客户方)的合作关系。这种合作需要两个团队共同协商:
下游团队对上游团队提出的领域需求
上游团队提供的服务采用什么样的协议与调用方式
下游团队针对上游服务的测试策略
上游团队给下游团队承诺的交付日期
当上游服务的协议或调用方式发生变更时,该如何控制变更
注意在很多业务系统中下游团队往往都不止一个。如何排定不同领域需求的优先级如何针对不同的领域需求建立统一的抽象都是上游团队需要考虑的问题。若上游服务还未提供下游团队应采取模拟上游服务的方式来规避可能存在的集成风险并且需要考虑上游团队不能按时履行交付承诺时的应对方案。上游团队需要及时就服务的任何变更与所有下游团队进行协商而下游团队的领域需求一旦有变也应及时告知上游团队。如果能够采用持续集成Continuous Integration为上、下游限界上下文建立集成测试、API 测试等自动化测试的构建与发布管道,可以更好地规避集成的风险,也能够更好地了解因为上游服务发生变更时对所有下游产生的影响。
例如我们在设计通知Notification上下文时作为上游服务的开发团队需要考虑各种信息通知的领域需求。从通知类型看可以是邮件、短信、微信推送和站内信息推送等多种方式。从通知格式看可能是纯文本、HTML 或微信文章。从通知内容看,可以是固定内容,也可能需要提供通知模板,由调用者提供数据填充到模板中的正确位置。
设计该服务时,我们既要考虑这些通知服务实现的多样化,又要考虑服务调用的简单与一致性。至于发送的通知内容,则需要上游团队事先定义通知上下文的领域模型。该领域模型既要覆盖所有的业务场景,又要保证模型的稳定性,同时还必须注意维持通知上下文的职责边界。
譬如说,我们在通知上下文中定义了 Message 与 Template 领域对象后者内部封装了一个HashMap<String, String>类型的属性。Map 的 key 对应模板中的变量value 则为实际填充的值。建模时,我们明确了通知上下文的职责,它仅负责模板内容正确地填充,并不负责对值的解析。这就是上游定义的契约,它清晰地勾勒了上下文之间协作的边界。倘若下游团队在填充通知模板的值时,还需要根据自己的业务规则进行运算,就应该在调用通知服务之前,首先在自己的限界上下文中进行计算,然后再将计算后的值作为模板的 value 传入。
遵奉者Conformist
我们需要从两个角度来理解遵奉者模式,即需求的控制权与对领域模型的依赖。
一个正常的客户方-供应方开发模式,是上游团队满足下游团队提出的领域需求;但当需求的控制权发生了逆转,由上游团队来决定是响应还是拒绝下游团队提出的请求时,所谓的“遵奉者”模式就产生了。从这个角度来看,我们可以将遵奉者模式视为一种“反模式”。糟糕的是在现实的团队合作中,这种情形可谓频频发生,尤其是当两个团队分属于不同的管理者时,牵涉到的因素就不仅仅是与技术有关了。所以说领域驱动设计提出的“限界上下文”实践,影响的不仅仅是设计决策与技术实现,还与企业文化、组织结构直接有关。许多企业推行领域驱动设计之所以不够成功,除了团队成员不具备领域驱动设计的能力之外,还要归咎于企业文化和组织结构层面。例如,企业的组织结构人为地制造了领域专家与开发团队的壁垒,又比如两个限界上下文因为利益倾轧而导致协作障碍,而团队领导的求稳心态,也可能导致领域驱动设计“制造”的变化屡屡碰壁,无法将这种良性的“变化”顺利地传递下去。
遵奉者还有一层意思是下游限界上下文对上游限界上下文模型的追随。当我们选择对上游限界上下文的模型进行“追随”时,就意味着:
可以直接重用上游上下文的模型(好的)
减少了两个限界上下文之间模型的转换成本(好的)
使得下游限界上下文对上游产生了模型上的强依赖(坏的)
做出遵奉模型决策的前提是需要明确这两个上下文的统一语言是否存在一致性,因为限界上下文的边界本身就是为了维护这种一致性而存在的。理想状态下,即使是上下游关系的两个限界上下文都应该使用自己专属的领域模型,因为原则上不同限界上下文对统一语言的观察视角多少会出现分歧,但模型转换的成本确实会令你左右为难。设计总是如此,没有绝对好的解决方案,只能依据具体的业务场景权衡利弊得失,以求得到相对好(而不是最好)的方案。这是软件设计让人感觉棘手的原因,却也是它如此迷人的魅力所在。
分离方式Separate Ways
分离方式的合作模式就是指两个限界上下文之间没有哪怕一丁点儿的丝毫关系。这种“无关系”仍然是一种关系,而且是一种最好的关系。这意味着我们无需考虑它们之间的集成与依赖,它们可以独立变化而互相不产生影响,还有什么比这更美好的呢?
在典型的电商网站中,支付上下文与商品上下文之间就没有任何关系,二者是“分离方式”的体现。虽然从业务角度理解,客户购买商品,确乎是为商品进行支付,但在商品上下文中,我们关心的是商品的价格(另一种可能是将价格作为一个独立的上下文),在支付上下文,关注的却是每笔交易的金额。商品价格影响的是订单上下文,支付上下文会作为订单上下文的上游,被订单上下文调用,但这种调用传递的是每条订单的总金额,支付上下文并不关心每笔订单究竟包含了哪些商品。唯一让支付上下文与商品上下文之间可能存在关联的因素,是二者的领域模型中都需要 Money 值对象。我们可以在这两个限界上下文中重复定义 Money 值对象。如果 Money 值对象其实还牵涉到复杂的货币转换以及高精度的运算逻辑,我宁可将类似 Money 这样的对象剥离到单独的上下文中,例如单独拎出来一个货币上下文。此时的货币上下文其实是支付上下文与商品上下文的共享内核:
“分离方式”的映射模式看起来容易识别,然而一旦系统的领域知识变得越来越复杂,导致多个限界上下文之间存在错综复杂的关系时,要识别两个限界上下文之间压根没有一点关系,就需要敏锐的“视力”了。这种没有关系的关系似乎无足轻重,其实不然,它对改进设计质量以及团队组织都有较大帮助。两个毫无交流与协作关系的团队看似冷漠无情,然而,正是这种“无情”才能促进它们独立发展,彼此不受影响。

View File

@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
019 上下文映射的通信集成模式
无论采用何种设计,限界上下文之间的协作都是不可避免的,应用边界的上下文映射模式会以更加积极的态度来应对这种不可避免的协作;从设计的角度来讲,就是不遗余力地降低限界上下文之间的耦合关系。防腐层与开放主机服务的目的正是如此。
防腐层Anticorruption Layer
防腐层其实是设计思想“间接”的一种体现。在架构层面,通过引入一个间接的层,就可以有效隔离限界上下文之间的耦合,这个间接的防腐层还可以扮演“适配器”的角色、“调停者”的角色、“外观”的角色,没错,这都是 GOF 设计模式中常见的几种结构型模式。
防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。因为不管是遵奉者模式,还是客户方-供应方模式,下游团队终究可能面临不可掌控的上游变化。在防腐层中定义一个映射上游限界上下文的服务接口,就可以将掌控权控制在下游团队中,即使上游发生了变化,影响的也仅仅是防腐层中的单一变化点,只要防腐层的接口不变,下游限界上下文的其他实现就不会受到影响。
我们可以通过下图来对比引入防腐层的价值:
显然,在没有引入防腐层时,下游上下文可能存在多处对上游上下文领域模型的依赖,一旦上游发生变更,就会影响到下游的多处实现;引入防腐层后,之前产生的多处依赖转为对防腐层的依赖,再由防腐层指向上游上下文,形成单一依赖。上游变更时,影响的仅仅是防腐层,下游上下文自身并未受到影响。
用以对付遗留系统时,防腐层可谓首选利刃。我在前面讲解限界上下文对遗留系统的应对时,已经述及采用“抽象分支”与“防腐层”的手法。对于遗留系统,我们不能粗暴地用新系统取代它,而应采用渐进的手段尽可能重用它的资产,剔除不好的设计与实现,完成逐步替换;我们可以将遗留系统视为一个整体的限界上下文,然后为调用它的下游上下文建立防腐层。由于防腐层是我们自己掌控的,就可以在其内动动手脚,例如,从调用者角度思考需要公开的服务接口,并引入领域驱动设计为其提炼出清晰的领域模型,然后再从遗留系统中去寻找对应的实现,慢慢将合适的代码搬移过来,适时对其重构。这种做法既保有了新设计的新鲜感,不受技术债的影响,又不至于走向极端,对旧有系统大动干戈,可谓选择了一条“中庸之道”,能够新旧并存地小步前行。
开放主机服务Open Host Service
如果说防腐层是下游限界上下文对抗上游变化的利器,那么开放主机服务就是上游服务用来吸引更多下游调用者的诱饵。设计开放主机服务,就是定义公开服务的协议,包括通信的方式、传递消息的格式(协议)。同时,也可视为是一种承诺,保证开放的服务不会轻易做出变化。
开放主机服务常常与发布语言Published Language模式结合起来使用。当然在定义这样的公开服务时为了被更多调用者使用需要力求语言的标准化在分布式系统中通常采用 RPCProtocol Buffer、WebService 或 RESTful。若使用消息队列中间件则需要事先定义消息的格式例如在我参与过的一个分布式 CIMS计算集成制造系统客户端与服务端以及服务端之间的通信皆以消息形式传递我们定义了如下的消息格式
Message——Name
——ID
——BodyMessageItemSequence
——Value
——ItemMessageItem
——SubValue
——SubItemMessageItem
采用这种消息格式,几乎所有的分布式服务都可以抽象为这样的接口:
public interface RemotingService {
/**
* @param serviceName为需要调用的远程服务名
* @param request为Message类型的request消息
* @return 返回Message类型的response消息
* @throws 自定义的RemotingException其中包含的message仍然为Message结构表达Error
*/
Message execute(String serviceName, Message request) throws RemotingException;
}
为了降低上游与下游限界上下文之间的依赖,防腐层与开放主机服务都是一种有效的手段,前者归属于下游限界上下文的范围,后者则属于上游限界上下文的边界,但二者是存在区别的,上游限界上下文作为被依赖方,往往会被多个下游限界上下文消费,如果需要引入防腐层,意味着需要为每个下游都提供一个几乎完全相似的防腐层,导致了防腐层的重复。因此,倘若上、下游限界上下文都在开发团队内部,又或者二者之间建立了良好的团队协作,我更倾向于在上游限界上下文中定义开放主机服务。当然,在极端情况下,可能需要在为上游限界上下文提供开放主机服务的同时,还需要为下游限界上下文定义防腐层。
在绘制上下文映射图时,我们往往用 ACL 缩写来代表防腐层,用 OHS 缩写代表开放主机服务。
发布/订阅事件
即使是确定了发布语言规范的开放主机服务,仍然会导致两个上下文之间存在耦合关系,下游限界上下文必须知道上游服务的 ABCAddress、Binding 与 Contract对于不同的分布式实现还需要在下游定义类似服务桩的客户端。例如在基于 Spring Cloud 的微服务架构中,虽然通过引入 Euraka 实现了对服务的注册与发现,降低了对 Address、Binding 的依赖,但仍然需要在下游限界上下文定义 Feign 客户端,你可以将这个 Feign 客户端理解为是真实服务的一个代理Proxy。基于代理模式我们要求代理与被代理的真实服务Subject保持相同的接口这就意味着一旦服务的接口发生变化就需要修改客户端代码。
采用发布/订阅事件的方式可以在解耦合方面走得更远。一个限界上下文作为事件的发布方,另外的多个限界上下文作为事件的订阅方,二者的协作通过经由消息中间件进行传递的事件消息来完成。当确定了消息中间件后,发布方与订阅方唯一存在的耦合点就是事件,准确地说,是事件持有的数据。由于业务场景通常较为稳定,我们只要保证事件持有的业务数据尽可能满足业务场景即可。这时,发布方不需要知道究竟有哪些限界上下文需要订阅该事件,它只需要按照自己的心意,随着一个业务命令的完成发布事件即可。订阅方也不用关心它所订阅的事件究竟来自何方,它要么通过 pull 方式主动去拉取存于消息中间件的事件消息,要么等着消息中间件将来自上游的事件消息根据事先设定的路由推送给它,通过消息中间件,发布方与订阅方完全隔离了。在上下文映射中,这种基于发布/订阅事件的协作关系,已经做到了力所能及的松耦合极致了。
以电商购物流程为例,从买家搜索商品并将商品加入到购物车开始,到下订单、支付、配送完成订单结束,整个过程由多个限界上下文一起协作完成。倘若以发布/订阅事件作为这些限界上下文之间的协作模式,则发布和订阅事件的流程如下所示:
如果将发布事件的限界上下文定义为上游,订阅事件的限界上下文定义为下游,则下表展现了事件在上下游限界上下文之间的流转:
ID
Event
Upstream Context
Downstream Context
1
ProductSelected
Product Context
Basket Context
2
OrderRequested
Basket Context
Order Context
3
InventoryRequested
Order Context
Inventory Context
4
AvailabilityValidated
Inventory Context
Order Context
5
OrderValidated
Order Context
Payment Context
6
PaymentProcessed
Payment Context
Order Context
7
OrderConfirmed
Order Context
Shipment Context
8
ShipmentDelivered
Shipment Context
Order Context
采用发布/订阅事件模式的限界上下文不必一定是分布式架构,关键在于负责传递事件的介质是什么?如果采用独立进程运行的消息中间件,例如 RabbitMQ 或者 Kafka可以更加有效地利用资源整个系统的可伸缩性会变得更好。然而考虑到进程间通信带来的成本以及维护事务一致性带来的阻碍我们也可以开发运行在同一个 JVM 进程中的事件总线Event Bus来负责事件的发布和订阅。我们还可以采用 Actor 模式支持事件的发布与订阅Actor 会维持一个 mailbox它相当于是一个轻量级的消息队列。以电商系统为例例如Order Context 的 OrderActor 接收 OrderRequested 事件Basket Context 的 BasketActor 负责处理 ConfirmBasket 命令:
class BasketActor(eventPublisher: ActorRef) extends Actor with ActorLogging {
def receive: Receive = {
case cmd: ConfirmBasket =>
// compose OrderRequested event with product list
eventPublisher ! OrderRequested(cmd.customerId(), cmd.products())
}
}
class OrderActor extends Actor with ActorLogging {
def receive: Receive = {
case event: OrderRequested =>
// validate order
}
}
发布/订阅事件模式是松耦合的但它有特定的适用范围通常用于异步非实时的业务场景。当然它的非阻塞特性也使得整个架构具有更强的响应能力因而常用于业务相对复杂却没有同步要求的命令Command场景。这种协作模式往往用于事件驱动架构或者 CQRSCommand Query Responsibility Segregation命令查询职责分离架构模式中。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
020 辨别限界上下文的协作关系(上)
在思考限界上下文之间的协作关系时,首先我们需要确定是否存在关系,然后再确定是何种关系,最后再基于变化导致的影响来确定是否需要引入防腐层、开放主机服务等模式。倘若发现协作关系有不合理之处,则需要反思之前我们识别出来的限界上下文是否合理。
限界上下文通信边界对协作的影响
确定限界上下文之间的关系不能想当然需得全面考虑参与到两个限界上下文协作的业务场景然后在场景中识别二者之间产生依赖的原因确定依赖的方向进而确定集成点需要注意的是限界上下文的通信边界对于界定协作关系至为关键。限界上下文的通信边界分为进程内边界与进程间边界这种通信边界会直接影响到我们对上下文映射模式的选择。例如采用进程间边界就需得考虑跨进程访问的成本如序列化与反序列化、网络开销等。由于跨进程调用的限制彼此之间的访问协议也不尽相同同时还需要控制上游限界上下文可能引入的变化一个典型的协作方式是同时引入开放主机服务OHS与防腐层ACL如下图所示
限界上下文 A 对外通过控制器Controller为用户界面层暴露 REST 服务而在内部则调用应用层的应用服务Application Service然后再调用领域层的领域模型Domain Model。倘若限界上下文 A 需要访问限界上下文 B 的服务则通过放置在领域层的接口Interface去访问但真正的访问逻辑实现则由基础设施层的客户端Client完成这个客户端就是上下文映射模式的防腐层。客户端访问的其实是限界上下文 B 的控制器,这个控制器处于基础设施层,相当于上下文映射模式的开放主机服务。限界上下文 B 访问限界上下文 C 的方式完全一致,在限界上下文 C 中则通过资源库Repository接口经由持久化Persistence组件访问数据库。
从图中可以看到,当我们在界定限界上下文的协作关系时,需要考虑分层架构设计。通常,我们会将分层架构的应用层、领域层与基础设施层都视为在限界上下文的边界之内。如果限界上下文并未采用“零共享架构”,那么,在考虑协作关系时还需要考虑数据库层是否存在耦合。
唯独分层架构的用户界面层是一个例外,我们在领域建模时,通常不会考虑用户界面层,它并不属于限界上下文。究其原因,在于用户界面层与领域的观察视角完全不同。用户界面层重点考虑的是用户体验,而非业务的垂直划分,更不会考虑到业务之间的高内聚、松耦合。许多时候,为了用户操作的方便性,减少用户的操作次数,提高用户体验,可能会在一个 UI 页面中聚合许多属于不同限界上下文的业务。我们可以看看亚马逊或京东的页面,例如,在“我的京东”页面下,几乎将整个电商系统中各方面的业务都一网打尽了。这不符合我们对限界上下文的划分原则。事实上,在“前后端分离”的架构中,用户界面层往往会作为后端服务的调用者,当然应该被排除在限界上下文之外了。
这里存在一个设计决策,即引入开放主机服务与防腐层是否必要?这就需要设计者权衡变化、代码重用、架构简单性的优先级。没有标准答案,而需结合具体的应用场景帮助你做出判断。我自然无法穷尽所有的业务场景,这里给出的无非是其中一种选择罢了。譬如说,倘若限界上下文采用进程内通信,那么下游限界上下文是否还需要通过客户端与控制器去访问,就值得斟酌了。如果需要考虑未来从进程内通信演化为进程间通信,则保留客户端及其接口就是有必要的。
说明:以上提到的限界上下文通信边界、领域驱动设计分层架构、零共享架构、代码模型结构以及北向网关、南向网关的知识,都会在后面章节详细阐述。
协作即依赖
如果限界上下文之间存在协作关系,必然是某种原因导致这种协作关系。从依赖的角度看,这种协作关系是因为一方需要“知道”另一方的知识,这种知识包括:
领域行为:需要判断导致行为之间的耦合原因是什么?如果是上下游关系,要确定下游是否就是上游服务的真正调用者。
领域模型:需要重用别人的领域模型,还是自己重新定义一个模型。
数据:是否需要限界上下文对应的数据库提供支撑业务行为的操作数据。
领域行为产生的依赖
所谓领域行为落到设计层面其实就是每个领域对象的职责职责可以由实体Entity、值对象Value Object来承担也可以是领域服务Domain Service或者资源库Repository乃至工厂Factory对象来承担。
对象履行职责的方式有三种Rebecca Wirfs-Brock 在《对象设计:角色、职责与协作》一书中总结为:
亲自完成所有的工作。
请求其他对象帮忙完成部分工作(和其他对象协作)。
将整个服务请求委托给另外的帮助对象。
如果我们选择后两种履行职责的形式,就必然牵涉到对象之间的协作。一个好的设计,职责一定是“分治”的,就是让每个高内聚的对象只承担自己擅长处理的部分,而将自己不擅长的职责转移到别的对象。《建筑的永恒之道》作者 Christepher Alexander 就建议,在遇到设计问题时尽量少用集权的机制。还是在《对象设计:角色、职责与协作》这本书,作者认为:
软件对象通过相互作用和共享责任联系在一起。在对象之间建立简单、一致的通信机制,避免了解决方案的集权性,局部变化的影响不应扩散到整个系统,这是系统的强适应性所要求的。当职责得以划分,组织有序,同时协作遵循可预测性模式,那么复杂的软件系统就更便于管理。
领域驱动设计提出的限界上下文事实上是架构层次的“分权”,通过它的边界让“职责得以划分,组织有序”,限界上下文之间的协作也“遵循可预测性模式”,就可以有效地控制业务复杂度与技术复杂度。因此,在考虑限界上下文的协作关系时,关键要辨别这些分离的职责,弄清楚到底是限界上下文内的对象协作,还是限界上下文之间的对象协作,主要考虑有如下两个方面:
职责由谁来履行?——这牵涉到领域行为该放置在哪一个限界上下文。
谁发起对该职责的调用?——倘若发起调用者与职责履行者在不同限界上下文,则意味着二者存在协作关系,且能够帮助我们确定上下游关系。
以电商系统的订单功能为例。考虑一个业务场景,客户已经选择好要购买的商品,并通过购物车提交订单,这就牵涉到一个领域行为:提交订单。假设客户属于客户上下文,而订单属于订单上下文,现在需要考虑提交订单的职责由谁来履行。
从电商系统的现实模型看,该领域行为由客户发起,也就是说客户应该具有提交订单的行为,这是否意味着应该将该行为分配给 Customer 聚合根其实不然我们需要注意现实模型与领域模型尤其是对象模型的区别。在“下订单”这个业务场景中Customer 是一个参与者,角色为买家。领域建模的一种观点认为:领域模型是排除参与者在外的客观世界的模型,作为参与者的 Customer 应该排除在这个模型之外。
当然,这一观点亦存在争议,例如,四色建模就不这样认为,四色建模建议在时标性对象与作为人的实体对象之间引入角色对象,也就是说,角色对象会作为领域模型的一份子。当然,我们不能直接给角色与模型的参与者划上等号。在 DCIData Context Interation模式中则需要在一个上下文Context通过识别角色来思考它们之间的协作关系。譬如在转账业务场景中银行账户 Account 作为数据对象Data参与到转账上下文的协作此时应抽象出 Source 与 Destination 两个角色对象。
说明:在战术设计内容中,我会再深入探讨领域建模、四色建模与 DCI 之间的关系与建模细节。
领域模型的确定总是会引起争论,毕竟每个人观察领域模型的角度不同,对设计的看法也不相同。领域模型最终要落实到代码实现,交给实践去检验设计的合理性,不要在领域建模过程中过多纠缠建模的细节,选择一个恰好合理的模型即可。从建模到设计,再从设计到编码开发,其实是一个迭代的过程,倘若在实现时确实发现模型存在瑕疵,再回过头来修改即可,孜孜以求领域模型的完美,纯属浪费时间,在建模过程中,最重要的是守住最根本的设计原则。在合理运用设计原则之前,要紧的是明确:我们究竟要解决什么问题?
这里的问题不是如何确定领域模型,而是要确定提交订单这个行为究竟应该分配给谁?首先,这牵涉到对象的职责分配问题。从语义相关性剖析,这个领域行为虽然由客户发起,但操作的信息(知识)主体其实是订单,这就意味着它们应该分配给订单上下文。这种分配实际上也符合面向对象设计原则的“信息专家模式”,即“信息的持有者即为操作该行为的专家”;其次,从分层架构的角度看,这里所谓的“由客户发起调用”,仅仅代表客户通过用户界面层发起对后端服务的请求,换言之,并不是由属于客户上下文的 Customer 领域对象发起调用。
后面我们会讲到,如果遵循整洁架构的思想,领域层应该处于限界上下文的核心。为了保证业务用例的完整性,并避免暴露太多领域协作的细节,领域驱动设计引入了应用层,它包裹了整个领域层;然而,应用层并不会直接与作为调用者的前端进行通信,通常的方式是引入 RESTful 服务,这个 RESTful 服务等同于上下文映射中的开放主机服务OHS又相当于是 MVC 模式中的控制器Controller属于基础设施层的组件。针对下订单这个场景客户通过用户界面层的 OrderController 发起调用。OrderController 收到请求后,在处理了请求消息的验证与转换工作后,又将职责转交给了 OrderAppService然后通过它访问领域层中的领域服务 PlaceOrderService如下图所示
下订单场景的实现代码如下所示:
@RestController
@RequestMapping(value = "/orders/")
public class OrderController {
@Autowired
private OrderAppService service;
@RequestMapping(method = RequestMethod.POST)
public void create(@RequestParam(value = "request", required = true) CreateOrderRequest request) {
if (request.isInvalid()) {
throw new BadRequestException("the request of placing order is invalid.");
}
Order order = request.toOrder();
service.placeOrder(order);
}
}
@Service
public class OrderAppService {
@Autowired
private PlaceOrderService orderService;
public void placeOrder(Order order) {
try {
placeOrderService.execute(order);
} catch (InvalidOrderException | Exception ex) {
throw new ApplicationException(ex.getMessage());
}
}
}
既然 PlaceOrderService、OrderAppService 与 OrderController 都属于订单上下文,而该行为调用的真正发起者又不是 Customer 领域对象,而是通过用户界面与系统进行交互操作的用户,因此在这个业务场景中,并不存在我们想象的因为客户下订单导致客户上下文对订单上下文在领域行为上的依赖。
在将调用职责分配给前端时,我们需要时刻保持谨慎,不能将对限界上下文调用的工作全都交给前端,以此来解除后端限界上下文之间的耦合。前端确乎是发起调用的最佳位置,但前提是:我们不能让前端来承担后端应该封装的业务逻辑。当一个领域行为成为另一个领域行为“内嵌”的一个执行步骤时,发起的调用者就不再是前端 UI因为该执行步骤组成了业务逻辑的一部分。例如在计算订单总价时需要根据客户的类别确定不同的促销策略然后根据促销策略计算订单的总价这里牵涉到四个领域行为
计算订单总价
获得客户类别
确定促销策略
计算促销折扣
后面三个领域行为都是为“计算订单总价”提供功能支撑的,这就是前面所谓的“内嵌”执行步骤。除了订单总价属于订单上下文的行为,获得客户类别属于客户上下文,而促销策略与折扣计算则属于促销上下文。因为产生了领域行为的依赖,它们会作为订单上下文的上游限界上下文。
这里其实存在设计上的变化,这取决于我们对职责的分层(在前面讲解的领域场景分析中介绍了职责的分层):
计算订单总价——订单上下文
获得客户类别——客户上下文
根据客户类别获得促销策略——促销上下文
通过促销策略计算促销折扣——促销上下文
当采用这种职责分层结构时,客户上下文与促销上下文就是订单上下文的上游。如果我们将获得客户类别视为促销上下文内含的业务逻辑,则职责的分层结构就变为:
计算订单总价——订单上下文
获得促销策略——促销上下文
获得客户类别——客户上下文
根据客户类别获得促销策略——促销上下文
通过促销策略计算促销折扣——促销上下文
这时候,订单上下文的上游为促销上下文,而在促销上下文内部,又需要去调用客户上下文的领域行为。
我们甚至可以对职责做进一步封装。因为对于计算订单总价而言,其实它并不关心促销折扣究竟是怎样得来的,也就是说,获得促销策略这个职责其实是计算促销折扣的细节,于是职责的分层结构再次变化:
计算订单总价——订单上下文
计算促销折扣——促销上下文
获得促销策略——促销上下文
获得客户类别——客户上下文
根据客户类别获得促销策略——促销上下文
通过促销策略计算促销折扣——促销上下文
这样的设计既可以减少其他限界上下文与订单上下文的协作,又可以减少彼此协作时需要依赖的领域行为。例如,我们如果希望降低订单上下文与促销上下文之间的耦合,从而避免促销上下文可能发生的变化对订单上下文的影响,就可以引入上下文映射中的防腐层。由于订单上下文只需要知道“计算促销折扣”这一个领域行为职责,防腐层接口的设计就变得更加容易:
package praticeddd.ecommerce.saleordercontext.domain;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
// DiscountCalculator 是定义在订单上下文的防腐层接口
import praticeddd.ecommerce.saleordercontext.interfaces.DiscountCalculator;
@Service
public class PriceCalculator {
@Autowired
private DiscountCalculator discountCalculator;
public Price priceFor(Order order) {
double discount = discountCalculator.calculate(order);
return order.totalPrice().multiply(discount);
}
}
package praticeddd.ecommerce.saleordercontext.interfaces;
public interface DiscountCalculator {
double calculate(Order order);
}
显然,不同的职责分层会直接影响到我们对限界上下文协作关系的判断。归根结底,还是彼此之间需要了解的“知识”起着决定作用。我们应尽可能遵循“最小知识法则”,在保证职责合理分配的前提下,产生协作的限界上下文越少越好。

View File

@ -0,0 +1,119 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
021 辨别限界上下文的协作关系(下)
领域模型产生的依赖
针对领域行为产生的依赖我们可以通过抽象接口来解耦。例如前面提到订单上下文对促销上下文的调用就通过引入防腐层ACL解除了对促销上下文的直接依赖然而限界上下文依赖的领域模型呢又该如何处理
与领域行为相同,我们首先还是要判断限界上下文是否真正对别的领域模型产生了依赖!例如,要查询客户拥有的所有订单信息,应该像如下代码那样将订单列表当做客户的一个属性吗?
public class Customer {
private List<SaleOrder> saleOrders;
public List<SaleOrder> getSaleOrders() {
return saleOrders;
}
}
如果采取这样的设计,自然就会在客户上下文中产生对 SaleOrder 领域模型的依赖,然而,这种实现并不可取。因为这样的设计会导致在加载 Customer 时需要加载可能压根儿就用不上的List<SaleOrder>,影响了性能。虽然通过延迟加载可以在一定程度解决性能问题,但既然存在延迟加载,就说明二者不一定总是同时需要。故而,延迟加载成为了判断领域实体对象设计是否合理的标志。
那么,是否可以用查询方法来替换属性?例如:
public class Customer {
public List<SaleOrder> saleOrders() {
// ...
}
}
在领域驱动设计中,我们通常不会这样设计,而是引入资源库对象来履行查询职责。若要查询订单,则 SaleOrder 会作为聚合根,对应的 SaleOrderRepository 作为资源库被放到订单上下文。在分层架构中,资源库对象可能会被封装到应用服务中,也可能直接暴露给作为适配器的 REST 服务中,例如,定义为:
@Path("/saleorder-context/saleorders/{customerId}")
public class SaleOrderController {
@Autowired
private SaleOrderRepository repository;
public List<SaleOrder> allSaleOrdersBy(CustomerId customerId) {
return repository.allSaleOrdersBy(customerId);
}
}
REST 服务的调用者并非客户上下文,而是前端或第三方服务以及客户端,貌似自然的客户与订单的包含关系,就如此被解开了。客户上下文与订单上下文并没有因为客户与订单的包含关系,使得它们二者产生协作。
如果确实存在跨限界上下文消费领域模型的场景,例如,订单上下文在查询订单时需要获得订单对应的商品信息时,我们该如何设计?存在两种设计决策:
在订单上下文中重用商品上下文的领域模型,即两个限界上下文之间采用遵奉者模式,且商品上下文作为上游。
在订单上下文中定义属于自己的与 Product 有关的领域模型。
在确定采用何种设计决策时,又会受到限界上下文边界的影响!进程内和进程间边界带来的影响是完全不同的。倘若商品上下文与订单上下文属于两个架构零共享的限界上下文,就不应采用重用领域模型的方式。因为这种模型的重用又导致了二者不再是“零共享”的。之所以采用零共享架构,是希望这两个限界上下文能够独立演化,包括部署与运行的独立性。倘若一个限界上下文重用了另一个限界上下文的领域模型,就意味着二者的代码模型是耦合的,即产生了包之间的依赖,而非服务的依赖。一旦该重用的模型发生了变化,就会导致依赖了该领域模型的服务也要重新部署。零共享架构带来的福利就荡然无存了。
如果二者之间的通信是发生在进程内,又该如何决策呢?
这其实是矛盾的两面,以 Product 领域对象为例:
重用当需求发生变更需要为商品增加新的属性时可以保证只修改一处避免了霰弹式修改但是如果两个限界上下文对商品的需求不相同Product 领域对象就需要同时应对两种不同的需求,随着需求的逐渐演化,可能会导致 Product 对象渐渐成为一个低内聚的对象,这就是所谓的“重用的代价”。
分离:在两个限界上下文中分别建立 Product 领域对象,这会带来代码的重复,当两个限界上下文都需要商品的新属性时,两边的领域模型都需要修改;倘若两个上下文对商品的需求并不相同,分离的两个模型就可以独自应对不同的需求变化了,这就是所谓的“独立演化”。
事实上在两个不同的限界上下文中为相同或相似的领域概念分别建立独立的领域模型为常见做法。例如Martin Fowler 在介绍限界上下文时,就给出了如下的设计图:
Sales Context 与 Support Context 都需要客户与商品信息,但它们对客户与商品的关注点是不相同的。销售可能需要了解客户的性别、年龄与职业,以便于他更好地制定推销策略,售后支持则不必关心这些信息,只需要客户的住址与联系方式。正如前面在讲解限界上下文的边界时,我们已经提到了限界上下文作为保持领域概念一致性的业务边界而存在。上图清晰地表达了为两个不同限界上下文分别建立独自的 Customer 与 Product 领域模型对象,而非领域模型的重用。
我个人倾向于分离的领域模型,因为相较于维护相似领域对象的成本,我更担心随着需求变化的不断发生需要殚精竭虑地规避(降低)重用的代价。
当我们选择分离方式时,很多人会担心所谓的“数据同步”问题,其实只要我们正确地进行了领域建模,这个问题是不存在的。大体说来,这种数据同步可能存在以下三种情况:
数据存在一处,领域模型仅仅是内存中的对象。例如,前面提到的订单上下文获得的商品对象。商品的信息无疑还是持久化在商品上下文的数据库中,在订单上下文定义的 Product 领域对象,仅仅是商品上下文中 Product 对象在内存中的一种转换,订单上下文并不承担持久化商品信息的职责。
数据按照不同的业务边界分散存储,但它们之间用相同的 Identity 来保持关联。例如,在前面介绍的限界上下文的业务边界时提到的 Product 案例。采购上下文、市场上下文、仓储上下文、推荐上下文与产品上下文对产品关注的属性并不相同,因此它们关心的数据应该被分散存储到各自的数据库中,并未出现数据冗余的情况。
数据虽然出现了冗余,但是导致它们产生变化的原因却不相同。例如,订单中包含了配送地址与联系人信息,这些信息在客户中同样存在。当客户修改了配送地址以及联系人信息时,是否需要同步保存在订单中的对应信息?事实上,这种情况并不需要同步。因为当客户提交订单后,订单的配送地址与联系人信息就与提交订单的买家脱离了关系,而被订单上下文单独管理。客户更新自己的配送地址,并不会影响到已有订单的配送地址。如果订单还未完成,客户希望修改订单中的配送地址和联系人信息,这个修改也是发生在订单上下文。
数据产生的依赖
所谓“数据产生的依赖”,来自于数据库。倘若严格遵循领域驱动设计,通常不会产生这种数据库层面的依赖,因为我们往往会通过领域模型的资源库去访问数据库,与数据库交互的对象也应该是领域模型对象(实体和值对象)。即使有依赖,也应该是领域行为与领域模型导致的。
有时候,出于性能或其他原因的考虑,一个限界上下文去访问属于另外一个限界上下文边界的数据时,有可能跳过领域模型,直接通过 SQL 或存储过程的方式对多张表执行关联查询CQRS 模式的读模型Read Model正是采用了这种形式。而在许多报表分析场景中这种访问跨限界上下文数据表的方式确实是最高效最简单的实现方式。当然这一切建立在一个前提即限界上下文之间至少是数据库共享的。
我们必须警惕这种数据产生的依赖没有绝对的理由我们不要轻易做出这种妥协。SQL 乃至存储过程形成的数据表关联,是最难进行解耦的。一旦我们的系统架构需要从单体架构(或数据库共享架构)演进到微服务架构,最大的障碍不是代码层面而是数据库层面的依赖,这其中就包括大量复杂的 SQL 与存储过程。
SQL 与存储过程的问题在于:
无法为 SQL 与存储过程编写单元测试,无法对其进行调试,也不利于缺陷排查。
SQL 与存储过程的可读性通常较差,也较难对其进行重用。
SQL 与存储过程的优化策略限制太大,虽然看起来 SQL 或存储过程的运行更贴近数据库,似乎性能更佳,但是无法在架构层面上对其进行进一步的优化,如分库分表以及水平扩展。
如果选择使用 SQL 与存储过程,当数据库自身出现瓶颈时,会陷入进退两难的境地,要么继续保持使用 SQL 与存储过程,但调优空间非常小;要么就采用垂直扩展,直接更换性能更好的机器。但这种应对方法无异于饮鸩止渴,不能解决根本问题。如果想要去掉 SQL 与存储过程,又需要对架构做重大调整,需要耗费较大的架构重构成本,对架构师的能力也要求颇高。在调整架构时,由于需要将 SQL 与存储过程中蕴含的业务逻辑进行迁移,还可能存在迁移业务逻辑时破坏原有功能的风险。选择架构调整,需得管理层具备壮士扼腕的勇气与魄力才行。
无论是零共享架构的分库模式,还是数据库共享模式,我们都需要尽量避免因为在数据库层面引起多个限界上下文的依赖。获取数据有多种方式,除了通过领域模型中聚合根的资源库访问数据之外,我们也可以通过数据同步的方式,对多个限界上下文的数据进行整合。例如,电商网站的推荐系统,它将作为整个系统中一个独立的限界上下文。为了获得更加精准精细的推荐结果,推荐系统需要获取买家的访问日志、浏览与购买的历史记录、评价记录,需要获得商品的类别、价格、销售量、评价分数等属性,需要获取订单的详细记录,是否有退换货等一系列的信息。但这并不意味着推荐上下文会作为客户上下文、商品上下文、订单上下文等的下游客户方,也未必需要在数据库层面对多张表执行关联操作。
推荐算法的数据分析往往是一个大数据量分析,它需要获得的数据通常存储在扮演 OLTPOn-Line Transaction Processing联机事务处理角色的业务数据库。业务数据库是支撑系统业务应用的主要阵地并被各种请求频繁读写。倘若该数据库成为瓶颈有可能会影响到整个电商系统的运行倘若推荐系统通过上游限界上下文的服务从各自的数据库中加载相关数据并存入到内存中进行分析会大量耗用网络资源和内存资源影响电商网站的业务系统也无法保证推荐系统的性能需求。
从数据分析理论来说,作为 OLTP 的业务数据库是面向业务进行数据设计的,这些数据甚至可能独立存在,并未形成数据仓库的主题数据特征,即集成了多个业务数据库,并能全面一致体现历史数据变化。因此,推荐系统需要利用大数据的采集技术,通过离线或实时流处理方式采集来自多数据源的多样化数据,然后可结合数据仓库技术,为其建立主题数据区和集市数据区,为 OLAPOn-Line Analytical Processing联机分析处理提供支撑也为如协同决策这样的推荐算法提供了数据支持。
当然,推荐系统需要“知道”的数据不仅限于单纯的客户数据、商品数据与订单数据,还包括针对客户访问与购买商品的行为数据,如查询商品信息、添加购物车、添加订单、提交评论等行为产生的数据,这些行为数据未必存储在业务数据库中,相反可能会以如下形式存储:
日志:即记录这些行为数据为日志信息。我们可以将每次产生的日志存放到 ElasticSearch 中,并作为推荐系统要访问的数据库,常见架构就是所谓的 ELKElasticSearch + LogStash + Kibana架构。
事件倘若采用事件溯源Event Sourcing模式每次行为都会触发一个事件并通过事件存储Event Store将它们存储到对应的数据库中以待推荐系统读取这些事件溯源数据结合业务数据运用推荐算法进行计算。
由于推荐系统需要分析的数据已经通过专门的数据采集器完成了多数据源数据的采集,并写入到属于推荐上下文的主题数据库中,因而并不存在与其他业务数据库之间的依赖。从实现看,推荐上下文作为一个独立的限界上下文,与其他限界上下文之间并不存在依赖关系,属于上下文映射的“分离方式”模式。从这个例子获得的经验是:技术方案有时候会影响到我们对上下文映射的识别。

View File

@ -0,0 +1,119 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
022 认识分层架构
分层架构是运用最为广泛的架构模式几乎每个软件系统都需要通过层Layer来隔离不同的关注点Concern Point以此应对不同需求的变化使得这种变化可以独立进行此外分层架构模式还是隔离业务复杂度与技术复杂度的利器《领域驱动设计模式、原理与实践》这样写道
为了避免将代码库变成大泥球BBoM并因此减弱领域模型的完整性且最终减弱可用性系统架构要支持技术复杂性与领域复杂性的分离。引起技术实现发生变化的原因与引起领域逻辑发生变化的原因显然不同这就导致基础设施和领域逻辑问题会以不同速率发生变化。
这里所谓的“以不同速率发生变化”其实就是引起变化的原因各有不同这正好是单一职责原则Single-Responsibility PrincipleSRP的体现。Robert Martin 认为单一职责原则就是“一个类应该只有一个引起它变化的原因”,换言之,如果有两个引起类变化的原因,就需要分离。单一职责原则可以理解为架构原则,这时要考虑的就不是类,而是层次,我们为什么要将业务与基础设施分开?正是因为引起它们变化的原因不同。
经典分层架构
分层架构由来已久,把一个软件系统进行分层,似乎已经成为了每个开发人员的固有意识,甚至不必思考即可自然得出,这其中最为经典的就是三层架构以及领域驱动设计提出的四层架构。
经典三层架构
在软件架构中经典三层架构自顶向下由用户界面层User Interface Layer、业务逻辑层Business Logic Layer与数据访问层Data Access Layer组成该分层架构之所以能够流行是有其历史原因的。在提出该分层架构的时代多数企业系统往往较为简单本质上都是一个单体架构Monolithic Architecture的数据库管理系统。这种分层架构已经是 Client-Server 架构的进化了,它有效地隔离了业务逻辑与数据访问逻辑,使得这两个不同关注点能够相对自由和独立地演化。一个经典的三层架构如下所示:
领域驱动设计的经典分层架构
领域驱动设计在经典三层架构的基础上做了进一步改良在用户界面层与业务逻辑层之间引入了新的一层即应用层Application Layer。同时一些层次的命名也发生了变化将业务逻辑层更名为领域层自然是题中应有之义而将数据访问层更名为基础设施层Infrastructure Layer则突破了之前数据库管理系统的限制扩大了这个负责封装技术复杂度的基础层次的内涵。下图为 Eric Evans 在其经典著作《领域驱动设计》中的分层架构:
该书对各层的职责作了简单的描述:
层次
职责
用户界面/展现层
负责向用户展现信息以及解释用户命令
应用层 
很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态,但它保有应用任务的进度状态
领域层 
本层包含关于领域的信息,这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层
基础设施层 
本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用
追溯分层架构的本源
当分层架构变得越来越普及时,我们的设计反而变得越来越僵化,一部分软件设计师并未理解分层架构的本质,只知道依样画葫芦地将分层应用到系统中,要么采用经典的三层架构,要么遵循领域驱动设计改进的四层架构,却未思考和探究如此分层究竟有何道理?这是分层架构被滥用的根源。
视分层Layer为一个固有的架构模式其根源应为 Frank Buschmann 等人著的《面向模式的软件架构》第一卷《模式系统》,该模式参考了 ISO 对 TCP/IP 协议的分层。《模式系统》对分层的描述为:
分层架构模式有助于构建这样的应用:它能被分解成子任务组,其中每个子任务组处于一个特定的抽象层次上。
显然,这里所谓的“分层”首先是一个逻辑的分层,对子任务组的分解需要考虑抽象层次,一种水平的抽象层次。既然为水平的分层,必然存在层的高与低;而抽象层次的不同,又决定了分层的数量。因此,对于分层架构,我们需要解决如下问题:
分层的依据与原则是什么?
层与层之间是怎样协作的?
分层的依据与原则
我们之所以要以水平方式对整个系统进行分层是我们下意识地确定了一个认知规则机器为本用户至上机器是运行系统的基础而我们打造的系统却是为用户提供服务的。分层架构中的层次越往上其抽象层次就越面向业务、面向用户分层架构中的层次越往下其抽象层次就变得越通用、面向设备。为什么经典分层架构为三层架构正是源于这样的认知规则其上面向用户的体验与交互居中面向应用与业务逻辑其下面对各种外部资源与设备。在进行分层架构设计时我们完全可以基于这个经典的三层架构沿着水平方向进一步切分属于不同抽象层次的关注点。因此分层的第一个依据是基于关注点为不同的调用目的划分层次。以领域驱动设计的四层架构为例之所以引入应用层Application Layer就是为了给调用者提供完整的业务用例。
分层的第二个依据是面对变化。分层时应针对不同的变化原因确定层次的边界,严禁层次之间互相干扰,或者至少把变化对各层带来的影响降到最低。例如,数据库结构的修改自然会影响到基础设施层的数据模型以及领域层的领域模型,但当我们仅需要修改基础设施层中数据库访问的实现逻辑时,就不应该影响到领域层了。层与层之间的关系应该是正交的,所谓“正交”,并非二者之间没有关系,而是垂直相交的两条直线,唯一相关的依赖点是这两条直线的相交点,即两层之间的协作点,正交的两条直线,无论哪条直线进行延伸,都不会对另一条直线产生任何影响(指直线的投影);如果非正交,即“斜交”,当一条直线延伸时,它总是会投影到另一条直线,这就意味着另一条直线会受到它变化的影响。
在进行分层时,我们还应该保证同一层的组件处于同一个抽象层次。这是分层架构的设计原则,它借鉴了 Kent Beck 在 Smalltalk Best Practice Patterns 一书提出的“组合方法”模式该模式要求一个方法中的所有操作处于相同的抽象层这就是所谓的“单一抽象层次原则SLAP这一原则可以运用到分层架构中。例如在一个基于元数据的多租户报表系统中我们特别定义了一个引擎层Engine Layer这是一个隐喻相当于为报表系统提供报表、实体与数据的驱动引擎。引擎层之下是基础设施层提供了多租户、数据库访问与元数据解析与管理等功能。在引擎层之上是一个控制层通过该控制层的组件可以将引擎层的各个组件组合起来分层架构的顶端是面向用户的用户展现层如下图所示
层与层之间的协作
在我们固有的认识中,分层架构的依赖都是自顶向下传递的,这也符合大多数人对分层的认知模型。从抽象层次来看,层次越处于下端,就会变得越通用越公共,与具体的业务隔离得越远。出于重用的考虑,这些通用和公共的功能往往会被单独剥离出来形成平台或框架,在系统边界内的低层,除了面向高层提供足够的实现外,就都成了平台或框架的调用者。换言之,越是通用的层,越有可能与外部平台或框架形成强依赖。若依赖的传递方向仍然采用自顶向下,就会导致系统的业务对象也随之依赖于外部平台或框架。
依赖倒置原则Dependency Inversion PrincipleDIP提出了对这种自顶向下依赖的挑战它要求“高层模块不应该依赖于低层模块二者都应该依赖于抽象”这个原则正本清源给了我们严重警告——谁规定在分层架构中依赖就一定要沿着自顶向下的方向传递我们常常理解依赖是因为被依赖方需要为依赖方调用方提供功能支撑这是从功能重用的角度来考虑的。但我们不能忽略变化对系统产生的影响与建造房屋一样我们自然希望分层的模块“构建”在稳定的模块之上谁更稳定抽象更稳定。因此依赖倒置原则隐含的本质是我们要依赖不变或稳定的元素类、模块或层也就是该原则的第二句话抽象不应该依赖于细节细节应该依赖于抽象。
这一原则实际是“面向接口设计”原则的体现,即“针对接口编程,而不是针对实现编程”。高层模块对低层模块的实现是一无所知的,带来的好处是:
低层模块的细节实现可以独立变化,避免变化对高层模块产生污染
在编译时,高层模块可以独立于低层模块单独存在
对于高层模块而言,低层模块的实现是可替换的
倘若高层依赖于低层的抽象必然会面对一个问题如何把具体的实现传递给高层的类由于在高层通过接口隔离了对具体实现的依赖就意味着这个具体依赖被转移到了外部究竟使用哪一种具体实现由外部的调用者来决定。只有在运行调用者代码时才将外面的依赖传递给高层的类。Martin Fowler 形象地将这种机制称为“依赖注入Dependency injection”。
为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来。
层之间的协作并不一定是自顶向下的传递通信,也有可能是自底向上通信。例如,在 CIMS计算机集成制造系统往往会由低层的设备监测系统监测侦听设备状态的变化。当状态发生变化时需要将变化的状态通知到上层的业务系统。如果说自顶向下的消息传递往往被描述为“请求或调用则自底向上的消息传递则往往被形象地称之为“通知”。倘若我们颠倒一下方向自然也可以视为这是上层对下层的观察故而可以运用观察者模式Observer Pattern在上层定义 Observer 接口,并提供 update() 方法供下层在感知状态发生变更时调用;或者,我们也可以认为这是一种回调机制。虽然本质上这并非回调,但设计原理是一样的。
如果采用了观察者模式,则与前面讲述的依赖倒置原则有差相仿佛之意,因为下层为了通知上层,需要调用上层提供的 Observer 接口。如此看来,无论是上层对下层的“请求(或调用)”,抑或下层对上层的“通知”,都颠覆了我们固有思维中那种高层依赖低层的理解。
现在,我们对分层架构有了更清醒的认识。我们必须要打破那种谈分层架构必为经典三层架构又或领域驱动设计推荐的四层架构这种固有思维,而是将分层视为关注点分离的水平抽象层次的体现。既然如此,架构的抽象层数就不是固定的,甚至每一层的名称也未必遵循固有(经典)的分层架构要求。设计系统的层需得结合该系统的具体业务场景而定。当然,我们也要认识到层次多少的利弊:过多的层会引入太多的间接而增加不必要的开支,层太少又可能导致关注点不够分离,导致系统的结构不合理。
我们还需要正视架构中各层之间的协作关系打破高层依赖低层的固有思维从解除耦合或降低耦合的角度探索层之间可能的协作关系。另外我们还需要确定分层的架构原则或约束例如是否允许跨层调用即每一层都可以使用比它低的所有层的服务而不仅仅是相邻低层。这就是所谓的“松散分层系统Relaxed Layered System”。

View File

@ -0,0 +1,90 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
023 分层架构的演化
分层架构是一种架构模式,但终归它的目的是为了改进软件的架构质量,我们在运用分层架构时,必须要遵守架构设计的最高原则,即建立一个高内聚、松耦合的软件系统架构。于是,许多设计大师们纷纷提出了自己的洞见。
整洁架构
在架构设计时,我们应设计出干净的应用层和领域层,保持它们对业务逻辑的专注,而不掺杂任何具体的技术实现,从而完成领域与技术之间的完全隔离,这一思想被 Robert Martin 称之为整洁架构Clean Architecture。下图展现了 Robert Martin 的这一设计思想:
该架构思想提出的模型并非传统的分层架构,而是类似于一个内核模式的内外层架构,由内及外分为四层,包含的内容分别为:
企业业务规则Enterprise Business Rules
应用业务规则Application Business Rules
接口适配器Interface Adapters
框架与驱动器Frameworks & Drivers
注意“企业业务规则”与“应用业务规则”的区别,前者是纯粹领域逻辑的业务规则,后者则面向应用,需要串接支持领域逻辑正常流转的非业务功能,通常为一些横切关注点,如日志、安全、事务等,从而保证实现整个应用流程(对应一个完整的用例)。
仔细解读这一架构模型,我们会发现许多有用的特征:
层次越靠内的组件依赖的内容越少,处于核心的 Entities 没有任何依赖。
层次越靠内的组件与业务的关系越紧密,因而越不可能形成通用的框架。
Entities 层封装了企业业务规则,准确地讲,它应该是一个面向业务的领域模型。
Use Cases 层是打通内部业务与外部资源的一个通道因而提供了输出端口Output Port与输入端口Input Port但它对外的接口展现的其实是应用逻辑或者说是一个用例。
Gateways、Controllers 与 Presenters 其本质都是适配器Adapter用于打通应用业务逻辑与外层的框架和驱动器实现逻辑的适配以访问外部资源。
系统最外层包括框架和驱动器,负责对接外部资源,不属于系统(仅指限界上下文而言)开发的范畴,但选择这些框架和驱动器,是属于设计决策要考虑的内容。这一层的一些组件甚至与要设计的系统不处于同一个进程边界。
我们学到了什么Robert Martin 的整洁架构将领域模型放在整个系统的核心,这一方面体现了领域模型的重要性,另外一方面也说明了领域模型应该与具体的技术实现无关。领域模型就是业务逻辑的模型,它应该是完全纯粹的,无论你选择什么框架,什么数据库,或者什么通信技术,按照整洁架构的思想都不应该去污染领域模型。如果以 Java 语言来实现,遵循整洁架构的设计思想,则所有领域模型对象都应该是 POJOPlain Ordinary Java Object。整洁架构的 Entities 层对应于领域驱动设计的领域层。
说明:注意 POJO 与 Java Bean 的区别。Java Bean 是指仅定义了为私有字段提供 get 与 set 方法的 Java 对象,这种 Java Bean 对象除了这些 get 和 set 方法之外几乎没有任何业务逻辑Martin Fowler 将这种对象称之为“贫血对象”根据这种贫血对象建立的模型就是“贫血模型”。POJO 指的是一个普通的 Java 对象,意味着这个 Java 对象不依赖除 JDK 之外的其他框架,是一个纯粹 Java 对象Java Bean 是一种特殊的 POJO 对象。在领域驱动设计中,如果我们遵循面向对象设计范式,就应避免设计出贫血的 Java Bean 对象;如果我们要遵循整洁架构设计思想,则应尽量将领域模型对象设计为具有领域逻辑的 POJO 对象。
属于适配器的 Controllers、Gateways 与 Presenters 对应于领域驱动设计的基础设施层。就我个人的理解来说适配器这个词并不能准确表达这些组件的含义反而更容易让我们理解为是对行为的适配我更倾向于将这些组件都视为是网关Gateway。对下例如针对数据库、消息队列或硬件设备可以认为是一个南向网关对于当前限界上下文是一种输出的依赖对上例如针对 Web 和 UI可以认为是一个北向网关对于当前限界上下文是一种输入的依赖。
这两种方向的网关与 Use Cases 层之间的关系是不尽相同的。北向网关会调用 Use Cases 层中表示应用逻辑的服务组件即发起的是一个由外向内的调用这种调用在整洁架构体系下是合乎道理的。Use Cases 层的服务组件并不需要关心北向网关的组件,例如,作为 RESTful 服务的 OrderController就是北向网关中的一个类它通过调用 Use Cases 层的 OrderAppService 服务来实现一个提交订单的业务用例。OrderAppService 并不需要知道作为调用者的 OrderController如果存在将 Entities 层的领域模型对象转换为 RESTful 服务的 Resource 对象,也是 OrderController 或者说北向网关的职责。
南向网关作为底层资源的访问者,往往成为 Use Cases 层甚至 Entities 层的被调用者。由于整洁架构思想并不允许内层获知外层的存在这就导致了我们必须在内层定义与外层交互的接口然后通过依赖注入的方式将外层的实现注入到内层中这也是“控制反转Inversion of Control”的含义即将调用的控制权转移到了外层。由是我们可以得出一个结论即南向网关封装了与外部资源DB、Devices、MQ交互的实现细节但其公开暴露的接口却需要被定义在内层的 Use Cases 或 Entities 中,这实际上阐释了为什么领域驱动设计要求将 Repository 的接口定义在领域层的技术原因。当然,将 Repository 接口定义在领域层还有其业务原因,在后面我会详细介绍。
六边形架构
整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更加清晰,减少不必要的耦合。它采用了内外层的架构模型弥补了分层架构无法体现领域核心位置的缺陷。由 Alistair Cockburn 提出的六边形架构Hexagonal Architecture在满足整洁架构思想的同时更关注于内层与外层以及与外部资源之间通信的本质
如上图所示,六边形架构通过内外两个六边形为系统建立了不同层次的边界。核心的内部六边形对应于领域驱动设计的应用层与领域层,外部六边形之外则为系统的外部资源,至于两个六边形之间的区域,均被 Cockburn 视为适配器Adapter并通过端口Port完成内外区域之间的通信与协作故而六边形架构又被称为端口-适配器模式port-adapter pattern。在第04课中我曾经给出了如下的设计图该图更加清晰地表达了领域驱动设计分层架构与六边形架构的关系同时也清晰地展现了业务复杂度与技术复杂度的边界
我在前面分析整洁架构时,将 Gateways、Controllers 与 Presenters 统一看做是网关,而在六边形架构中,这些内容皆为适配器。事实上,它们代表的含义是一致的,不同的命名代表的是对其职责认识上的不同。如果认为是“网关”,则将该组件的实现视为一种门面,内部负责多个对象之间的协作以及职责的委派;如果认为是“适配器”,则是为了解决内外协议(数据协议与服务接口)之间的不一致而进行的适配。若依据领域驱动设计的分层架构,则无论网关还是适配器,都属于基础设施层的内容。
无论理解为网关还是适配器通过这种架构思想都可以认识到在基础设施层的组件应该是轻量级的实现甚至可以认为它不过是对第三方框架或平台有选择的调用罢了归根结底它虽然是技术实现却是为业务场景提供服务的。例如需要操作订单数据库DB 适配器就是一个传递通道,将需要操作的领域模型传递给它,最后返回结果,真正的实现则通过 JDBC、Hibernate、MyBatis 或 Spring Data 等第三方框架来完成。同理如果需要为前端提供订单服务能力Web 适配器负责验证与转换前端消息,至于请求到资源的路由等功能皆由 Spring Boot、DropWizard 或 Airlift 等 REST 框架来完成。所以说这里所谓的“适配器”与“端口”其实就是领域与外部资源的一个转换通道。适配器向内的一端连接了 Application 的领域,向外的一端则通过端口连接了外部资源。
六边形架构通过内外六边形的两个不同边界清晰地展现了领域与技术的边界,同时,外部六边形还体现了系统的进程边界,有助于我们思考分布式架构的物理视图,并通过识别端口来引导我们专注于六边形之间的通信机制,这些通信机制可能包括:
与外部资源(数据库、文件、消息队列等)之间的通信
与 Web 和 UI 等用户界面之间的通信
与第三方服务之间的通信
与其他六边形边界之间的通信
微服务架构
Toby Clemson 在《微服务架构的测试策略》一文中深入探讨了如何对微服务架构制定测试策略。要明确如何对这样的系统进行测试就需要明确该系统架构的组成部分以及各组成部分承担的职责同时还需要了解各组成部分之间的协作关系。为此Toby 在这篇文章中给出了一个典型的微服务架构,如下图所示:
该架构图并未严格按照分层架构模式来约定各个组件的位置与职责,这是完全合理的设计!当我们需要将一个分层架构进行落地实践时,在任何一门语言中我们都找不到所谓 layer 的明确语法。在 Java 语言中,我们可以通过 package 与 module 去划分包与模块,在 Ruby 语言中我们也可以限定 module 的范畴,但我们并不能通过某种语法甚至语法糖去规定 layer 的边界。所以在编码实现中layer 其实是一个松散且不够严谨的逻辑概念,即使我们规定了层的名称以及各层的职责,但各种“犯规行为”依然屡见不鲜。与其如此,不如将各个组件在逻辑架构中的位置与职责明确定义出来。对于系统的概念模型与设计模型,我们要明确分层架构的本质与设计原则;对于代码模型,分层架构则主要负责设计指导,并酌情弱化层在代码模型中的意义,强化对包与模块的划分。
上图的逻辑边界代表了一个微服务,这是基于微服务的设计原则——“每个微服务的数据单独存储”,因此需要将物理边界(图中定义为网络边界)外的数据库放在微服务的内部。
整幅图的架构其实蕴含了两个方向:自顶向下和由内至外。
外部请求通过代表协议Protocol的 Resources 组件调用 Service Layer、Domain 或 Repositories如果需要执行持久化任务则通过 Repositories 将请求委派给 ORM进而访问网络边界外的数据库。所谓“外部请求”可以是前端 UI 或第三方服务,而 Resource 组件就是我们通常定义的 Controller对应于上下文映射中的开放主机服务。之所以命名为 Resources则是因为 REST 架构是一种面向资源的架构它将服务操作的模型抽象为资源Resource这是自顶向下的方向。
若当前微服务需要调用外部服务External Service且外部服务籍由 HTTP 协议通信,就需要提供一个 HTTP Client 组件完成对外部服务的调用。为了避免当前微服务对外部服务的强依赖,又或者对客户端的强依赖,需要引入 Gateways 来隔离。事实上,这里的 Gateways 即为上下文映射中的防腐层,这是由内至外的方向。
说明:文中的微服务架构图虽然由 Toby Clemson 在《微服务架构的测试策略》一文中给出,但肖然在《分层架构的代码架构》一文中又明确提出这一架构图来自 Martin Fowler。究竟是谁的创见我就此咨询了肖然肖然说他亲自问过老马即 Martin Fowler老马说这个架构是他认为的。Toby 的文章本身就发表在老马的官方 biliki 上,作者在文章的开篇对老马表示了致谢。

View File

@ -0,0 +1,88 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
024 领域驱动架构的演进
我们回顾了经典三层架构与领域驱动设计四层架构,然后又对分层架构模式的产生与设计原则做了一次历史回顾。我们先后参考了 Robert Martin 的整洁架构、Cockburn 的六边形架构以及 Toby Clemson 给出的微服务架构模型。现在,是时候为领域驱动设计的架构模型做一次总结陈词了。然而事情并未结束,因为任何技术结论都并非句点,而仅仅代表了满足当时技术背景的一种判断,技术总是在演进,领域驱动架构亦是如此。与其关心结果,不如将眼睛投往这个演进的过程,或许风景会更加动人。
根据“依赖倒置原则”与 Robert Martin 提出的“整洁架构”思想,我们推翻了 Eric Evans 在《领域驱动设计》书中提出的分层架构。Vaughn Vernon 在《实现领域驱动设计》一书中给出了改良版的分层架构,他将基础设施层奇怪地放在了整个架构的最上面:
整个架构模型清晰地表达了领域层别无依赖的特质,但整个架构却容易给人以一种错乱感。单以这个分层模型来看,虽则没有让高层依赖低层,却又反过来让低层依赖了高层,这仍然是不合理的。当然你可以说此时的基础设施层已经变成了高层,然而从之前分析的南向网关与北向网关来说,基础设施层存在被“肢解”的可能。坦白讲,这个架构模型仍然没有解决人们对分层架构的认知错误,例如它并没有很好地表达依赖倒置原则与依赖注入。还需要注意的是,这个架构模型将基础设施层放在了整个分层架构的最顶端,导致它依赖了用户展现层,这似乎并不能自圆其说。我们需要重新梳理领域驱动架构,展示它的演进过程。
该怎么演进领域驱动架构?可以从两个方向着手:
避免领域模型出现贫血模型
保证领域模型的纯粹性
避免贫血的领域模型
我们需要回顾经典的 Java 三层架构对领域模型的设计。在这个三层架构中,领域逻辑被定义在业务逻辑层的 Service 对象中,至于反映了领域概念的领域对象则被定义为 Java Bean这些 Java Bean 并没有包含任何领域逻辑,因此被放在了数据访问层。注意,这是经典三层架构的关键,即代表领域概念的 Java Bean 被放在了数据访问层,而非业务逻辑层。 经典三层架构采用了 J2EE 开发的 DAO 模式即将访问数据库的逻辑封装到数据访问对象Data Access Object中。这些 DAO 对象仅负责与数据库的交互,并实现领域对象到数据表的 CRUD增删改查操作因而也被放到了数据访问层中如下图所示
如果以面向对象设计范式进行领域建模,我们需要遵循面向对象的设计原则,其中最重要的设计原则就是“数据与行为应该封装在一起”,这也是 GRASP 模式中“信息专家模式”的体现。前面提及的 Java Bean 由于仅包含了访问私有字段的 get 和 set 方法可以说是对面向对象设计原则的“背叛”Martin Fowler 则将这种没有任何业务行为的对象称之为“贫血对象”。基于这样的贫血对象进行领域建模,得到的模型则被称之为“贫血模型”。这种贫血模型被认为是简单的,却不具备对象的丰富表达能力,当业务逻辑变得复杂时,在表达领域模型方面就会变得“力不从心”,无法有效应对重用与变化,且可能导致臃肿的“上帝类”。贫血模型的种种问题会在战术设计中再做深入探讨,这里我们姑且给出一个结论,即:在面向对象设计背景下,当我们面对相对复杂的业务逻辑时,应避免设计出贫血模型。
要避免贫血模型就需要合理地将操作数据的行为分配给这些领域模型对象Domain Model即战术设计中的 Entity 与 Value Object而不是前面提及的 Service 对象。由于领域模型对象包含了领域逻辑,就需要从数据访问层转移到业务逻辑层。至于那些不属于任何领域模型对象的领域逻辑,仍然放到 Service 对象中。由于 DAOs 对象需要操作这些领域模型对象,使得处于数据访问层的 DAOs 对象必须依赖领域层的领域模型对象,也就是说,要避免贫血的领域模型,就不可能避免底层的数据访问层对业务逻辑层的依赖。
从分层的职责和意义讲,一个系统的基础不仅仅限于对数据库的访问,还包括访问诸如网络、文件、消息队列或者其他硬件设施,因此 Eric Evans 将其更名为“基础设施层”是非常合理的。至于将业务逻辑层更名为领域层也是题中应有之义。遵循整洁架构思想,基础设施层属于架构的外层,它依赖于处于内部的领域层亦是正确的做法。在领域层,封装了领域逻辑的 Services 对象则可能需要持久化领域对象,甚至可能依赖基础设施层的其他组件。于是,之前的分层架构就演进为:
保证领域模型的纯粹性
若将整个层次看做一个整体,在刚才给出的分层架构图中,加粗的两条依赖线可以清晰地看到领域层与基础设施层之间产生了“双向依赖”。在实际开发中,若这两层又被定义为两个模块,双向依赖就成为了设计坏味,它导致了两个层次的紧耦合。此时,领域模型变得不再纯粹,根由则是高层直接依赖了低层,而不是因为低层依赖了高层。故而我们需要去掉右侧 Services 指向 DAOs 的依赖。
DAOs 负责访问数据库,其实现逻辑是容易变化的。基于“稳定依赖原则”,我们需要让领域层建立在一个更加稳定的基础上。抽象总是比具体更稳定,因此,改进设计的方式是对 DAOs 进行抽象,然后利用依赖注入对数据访问的实现逻辑进行注入,如下图所示:
DAOs 的抽象到底该放在哪里?莫非需要为基础设施层建立一个单独的抽象层吗?这牵涉到我们对数据库访问的认知。任何一个软件系统的领域对象都存在其生命周期,代表领域逻辑的业务方法其实就是在创造它,发现它,更新它的状态,最后通常也会销毁它。倘若部署软件系统的计算机足够强劲与稳定,就不再需要任何外部资源了;这时,对领域对象的生命周期管理就变成了对普通对象的内存管理。因此,从业务角度看,管理对象的生命周期是必须的,访问外部资源却并非必须。只是因为计算机资源不足以满足这种稳定性,才不得已引入外部资源罢了。也就是说,访问这些领域对象属于业务要素,而如何访问这些领域对象(如通过外部资源),则属于具体实现的技术要素。
从编码角度看领域对象实例的容身之处不过就是一种数据结构而已区别仅在于存储的位置。领域驱动设计将管理这些对象的数据结构抽象为资源库Repository。通过这个抽象的资源库访问领域对象自然就应该看作是一种领域行为。倘若资源库的实现为数据库并通过数据库持久化的机制来实现领域对象的生命周期管理则这个持久化行为就是技术因素。
结合前面对整洁架构的探讨,抽象的资源库接口代表了领域行为,应该放在领域层;实现资源库接口的数据库持久化,需要调用诸如 MyBatis 这样的第三方框架,属于技术实现,应该放在基础设施层。于是,分层架构就演进为:
由于抽象的 Repositories 被搬迁至领域层,图中的领域层就不再依赖任何其他层次的组件或类,成为一个纯粹的领域模型。我们的演进正逐步迈向整洁架构!
用户展现层的变迁
现代软件系统变得日趋复杂,对于一个偏向业务领域的分层架构,领域层的调用者决不仅限于用户展现层的 UI 组件,比如说可以是第三方服务发起对领域逻辑的调用。即使是用户展现层,也可能需要不同的用户交互方式与呈现界面,例如 Web、Windows 或者多种多样的移动客户端。因此在分层架构中,无法再用“用户展现层”来涵盖整个业务系统的客户端概念。通常,我们需要采用前后端分离的架构思想,将用户展现层彻底分离出去,形成一个完全松耦合的前端层。
不管前端的展现方式如何,它的设计思想是面向调用者,而非面向领域。因此,我们在讨论领域驱动设计时,通常不会将前端设计纳入到领域驱动设计的范围。有人尝试将领域驱动设计引入到前端设计中,那是将前端自身当做一种领域。在设计后端 API 时,我们确乎需要从调用者的角度考虑 API 的定义,并确定从 Domain Model或者 Service Model又或者是 Resource Model到 View Model 的转换又或者考虑引入所谓“DTOData Transfer Object数据传输对象但这些都只限于后端 API 协议的设计。
准确地讲,前端可以视为是与基础设施层组件进行交互的外部资源,如前面整洁架构中的 Web 组件与 UI 组件。为了简化前端与后端的通信集成我们通常会为系统引入一个开放主机服务OHS为前端提供统一而标准的服务接口。该接口实际上就是之前整洁架构中提及的 Controllers 组件,也即我提出的基础设施层的北向网关。于是,分层架构就演变为:
这个分层架构展现了“离经叛道”的一面因为基础设施层在这里出现了两次但同时也充分说明了基础设施层的命名存在不足。当我们提及基础设施Infrastructure总还是会想当然地将其视为最基础的层。同时这个架构也凸显了分层架构在表现力方面的缺陷。
引入应用层
即使我们分离了前后端,又引入了扮演北向网关角色的 Controllers都不可规避一个问题那就是领域层的设计粒度过细。由于有了 Controllers我们可以将 Controllers 看成是领域层的客户端,这就使得它需要与封装了 Entity 与 Value Object 的 Aggregate、Services 以及抽象的 Repositories 接口协作。基于 KISSKeep It Simple and Stupid原则或最小知识原则我们希望调用者了解的知识越少越好调用变得越简单越好这就需要引入一个间接的层来封装这就是应用层存在的主要意义
领域驱动分层架构中的应用层其实是一个外观Facade。GOF 的《设计模式》认为外观模式的意图是“为子系统中的一组接口提供一个一致的接口,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。”我们要理解“高层接口”的含义。一方面,它体现了一个概念层次的高低之分,以上图的分层架构来说,应用层是高层抽象的概念,但表达的是业务的含义,领域层是底层实现的概念,表达的是业务的细节。领域驱动设计要求应用层“不包含业务逻辑”,但对外它却提供了一个一致的体现业务用例的接口。注意,这里的接口概念并非指 Java 或 C# 等语言的 interface 语法。
基础设施层的本质
引入应用层后,整个分层架构的职责变得更加清晰了,唯一显得较为另类的是同为灰色部分的基础设施层。目前,该分层架构图的基础设施层被分成了两个,分别位于应用层上端和领域层下端。从依赖关系看,处于领域层下端的基础设施层是通过实现抽象 Repository 接口导致的。虽然我也可以将其置于领域层甚至应用层上端,以此来表达这种依赖关系;但我仍然选择保留原来的层次位置,我希望通过该图清晰地体现所谓“北向网关”与南向网关“的语义。正如我在前面分析整洁架构思想时,提到“属于适配器的 Controllers、Gateways 与 Presenters 对应于领域驱动设计的基础设施层。”我们将整洁架构、六边形架构与领域驱动设计的四层架构综合起来考虑,可以得到结论:
Controllers + Gateways + Presenters = Adapters = Infrastructure Layer
我个人认为这些组件确乎有适配的语义将它们视为适配器Adapter并无不对之处但我觉得 Martin Fowler 在《企业应用架构模式》中提出的网关Gateway模式似乎更准确。Martin Fowler 对该模式的定义为An object that encapsulates access to an external system or resource. (封装访问外部系统或资源行为的对象。)基础设施层要做的事情不正是封装对外部系统或资源的访问吗?至于“适配”的语义,仅仅是这种封装的实现模式罢了,更何况在这些组件中,不仅仅做了适配的工作。基于此,我才将这些组件统统视为“网关”,并根据其方向分别划分为北向网关与南向网关。理解网关的含义,可以帮助我们更好地理解基础设施层的本质。扮演网关角色的组件其实是一个出入口(某种情况下,网关更符合六边形架构中端口+适配器的组合概念),所以它们的行为特征是:网关组件自身会参与到业务中,但真正做的事情只是对业务的支撑,提供了与业务逻辑无关的基础功能实现。
经历了多次演进,我们的分层架构终于在避免贫血模型的同时保证了领域逻辑的纯粹性,有效地隔离了业务复杂度与技术复杂度。演进后的分层架构既遵循了整洁架构思想,又参考了六边形架构与微服务架构的特点。但我们不能说这样的分层架构就是尽善尽美的,更不能僵化地将演化得来的分层架构视为唯一的标准。分层架构是一种架构模式,遵循了“关注点分离”原则。因此,在针对不同限界上下文进行分层架构设计时,还需要结合当前限界上下文的特点进行设计,合理分层,保证结构的清晰和简单。

View File

@ -0,0 +1,435 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
025 案例 层次的职责与协作关系(图文篇)
经历多次演进,我们已经初步得到了符合领域驱动设计思想的分层架构,但这种架构仅仅是一种静态的逻辑划分,在实现一个业务用例时,各层之间是怎么协作的,我们似乎还不得而知。辨别这种动态的协作关系,还是应该从职责的角度入手,即必须清楚地理解分层架构中每个逻辑层的职责。
一味的理论讲解可能已经让爱好案例与代码的程序员昏昏欲睡了,何况用纯理论知识来讲解职责与协作,也让我力有未逮。不如通过一个具体案例,来说明层次的职责以及层次之间的协作关系。还是以电商系统的下订单场景为例,在买家提交订单时,除了与订单直接有关的业务之外,还需要执行以下操作。
订单数据的持久化OrderRepository 提供插入订单功能。它属于支撑订单提交业务的基础功能,但将订单持久化到数据库的实现 OrderMapper 并不属于该业务范畴。
发送通知邮件NotificationService 提供通知服务。它属于支撑通知业务的基础功能,但邮件发送的实现 EmailSender 却不属于该业务范畴。
异步发送消息给仓储系统:提交订单成功后需要异步发送消息 OrderConfirmed 给仓储系统这一通信模式是通过消息队列来完成的。EventBus 发送 OrderConfirmed 事件属于支撑订单提交成功的基础功能,但发送该事件到 RabbitMQ 消息队列的 RabbitEventBus 则不属于该业务范畴。
同时,为了用户界面客户端或第三方服务的分布式调用,需要通过 OrderController 暴露 RESTful 服务。它本身不提供任何业务实现,而是通过将请求委派给应用层的 OrderAppService 来完成订单的提交。
下图体现了前述三个流程在各层之间以及系统内外部之间的协作关系。注意,在这里我将牵涉到的类型放在了同一个限界上下文中,如果牵涉到多个限界上下文之间的协作,实现会略有不同,对应的代码模型也将有所调整。我会在后续内容中深入探讨限界上下文之间的协作对代码模型的影响。
基础设施层的 OrderController 扮演了北向网关的角色,承担了与用户界面层或第三方服务交互的进出口职责。它通过 Spring Boot 来完成对 HTTP 请求的响应、路由和请求/响应消息的序列化与反序列化。它的自有职责仅仅是对请求/响应消息的验证,以及对 OrderAppService 的调用。或许有人会质疑处于后端顶层的控制器为何属于基础设施层?但我认为这样的分配是合理的,因为 Controller 要做的事情与基础设施层所要履行的职责完全匹配,即它提供的是 REST 服务的基础功能。
基础设施层南向网关包括 OrderMapper、EmailSender 和 RabbitEventBus它们对内为具体的某个业务提供支撑功能对外则需要借助框架或驱动器访问外部资源。与北向网关不同对它们的调用由属于内层的应用服务 OrderAppService 发起,因此需要为它们建立抽象来解除内层对外层的依赖。前面已经分析,由于 Repository 提供的方法分属领域逻辑,故而将 OrderMapper 所要实现的接口 OrderRepository 放到核心的领域层。至于 EmailSender 与 RabbitEventBus 各自的抽象 NotificationService 与 EventBus 并未代表领域逻辑,为了不污染领域层的纯洁性,放在应用层似乎更为合理。
无论是北向网关还是南向网关,它们都要与外部资源进行协作,不管是对内/外协议的适配还是对内部协作对象的封装本质上它们只做与业务直接有关的基础功能。真正与业务无关的通用基础功能是与具体某个软件系统无关的属于更加基础和通用的框架。例如OrderController 调用的 Spring Boot APIsEmailSender 调用的 JavaMail APIs、OrderMapper 调用的 MyBatis APIs 以及 RabbitEventBus 调用的 RabbitMQ APIs都是这样的通用框架。它们是系统代码边界外的外部框架通常为第三方的开源框架或商业产品即使是团队自行研发也不应该属于当前业务系统的代码模型。
我们可以基于这个案例归纳各个层次的职责。
领域层:包含 PlaceOrderService、Order、Notification、OrderConfirmed 与抽象的 OrderRepository封装了纯粹的业务逻辑不掺杂任何与业务无关的技术实现。
应用层:包含 OrderAppService 以及抽象的 EventBus 与 NotificationService提供对外体现业务价值的统一接口同时还包含了基础设施功能的抽象接口。
基础设施层:包含 OrderMapper、RabbitEventBus 与 EmailSender为业务实现提供对应的技术功能支撑但真正的基础设施访问则委派给系统边界之外的外部框架或驱动器。
注意这里定义了两个分属不同层次的服务二者极容易混淆。PlaceOrderService 是领域服务定义在领域层中OrderAppService 是应用服务,定义在应用层中。这二者的区别属于战术设计的层面,我会在之后的战术设计讲解中深入阐释,我的博客《如何分辨应用服务与领域服务》也有比较详细的介绍。
OrderController 的实现代码如下所示:
package practiceddd.ecommerce.ordercontext.infrastucture;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.beans.factory.annotation.Autowired;
import practiceddd.ecommerce.ordercontext.infrastructure.message.CreateOrderRequest;
import practiceddd.ecommerce.ordercontext.application.OrderAppService;
import practiceddd.ecommerce.ordercontext.domain.Order;
@RestController
@RequestMapping(value = "/orders/")
public class OrderController {
@Autowired
private OrderAppService service;
@RequestMapping(method = RequestMethod.POST)
public void create(@RequestParam(value = "request", required = true) CreateOrderRequest request) {
if (request.isInvalid()) {
throw new BadRequestException("the request of placing order is invalid.");
}
Order order = request.toOrder();
service.placeOrder(order);
}
}
应用服务 OrderAppService 的代码如下所示:
package practiceddd.ecommerce.ordercontext.application;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
import practiceddd.ecommerce.ordercontext.domain.Order;
import practiceddd.ecommerce.ordercontext.domain.OrderCompleted;
import practiceddd.ecommerce.ordercontext.domain.Notification;
import practiceddd.ecommerce.ordercontext.domain.OrderNotification;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
@Serivce
public class OrderAppService {
@Autowired
private NotificationService notificationService;
@Autowired
private EventBus eventBus;
@Autowired
private PlaceOrderService placeOrderService;
public void placeOrder(Order order) {
try {
placeOrderService.execute(order);
notificatonService.send(composeNotification(order));
eventBus.publish(composeEvent(order));
} catch (InvalidOrderException | Exception ex) {
throw new ApplicationException(ex.getMessage());
}
}
private Notification composeNotification(Order order) {
// 组装通知邮件的内容,实现略
}
private OrderConfirmed composeEvent(Order order) {
// 组装订单确认事件的内容,实现略
}
}
既然 OrderAppService 属于应用层的应用服务,它就不应该包含具体的业务逻辑。倘若我们将发送邮件和异步消息发送视为“横切关注点”,那么在应用服务中调用它们是合乎情理的;然而,通过 Order 组装 Notification 与 OrderConfirmed 的职责,却应该放在领域层,因为基于订单去生成邮件内容以及发布事件包含了业务逻辑与规则。问题出现!由于这两个对象是由领域层生成的对象,我们该如何将领域层生成的对象传递给处于它之上的应用层对象?
有三种解决方案可供选择。
第一种方案是将组装通知邮件与订单确认事件的职责封装到领域层的相关类中,然后在应用层调用这些类的方法,如此可以减少应用层的领域逻辑:
package practiceddd.ecommerce.ordercontext.domain;
import org.springframework.stereotype.Service;
@Service
public class NotificationComposer {
public Notification compose(Order order) {
// 实现略
}
}
package practiceddd.ecommerce.ordercontext.domain;
import org.springframework.stereotype.Service;
@Service
public class OrderConfirmedComposer {
public OrderConfirmed compose(Order order) {
// 实现略
}
}
则应用服务就可以简化为:
package practiceddd.ecommerce.ordercontext.application;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
import practiceddd.ecommerce.ordercontext.domain.Order;
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmed;
import practiceddd.ecommerce.ordercontext.domain.Notification;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
import practiceddd.ecommerce.ordercontext.domain.NotificationComposer;
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmedComposer;
@Service
public class OrderAppService {
@Autowired
private NotificationService notificationService;
@Autowired
private EventBus eventBus;
@Autowired
private PlaceOrderService placeOrderService;
@Autowired
private NotificationComposer notificationComposer;
@Autowired
private OrderConfirmedComposer orderConfirmedComposer;
public void placeOrder(Order order) {
try {
placeOrderService.execute(order);
notificatonService.send(notificationComposer.compose(order));
eventBus.publish(orderConfirmedComposer.compose(order));
} catch (InvalidOrderException | Exception ex) {
throw new ApplicationException(ex.getMessage());
}
}
}
采用这种方案的代码结构如下所示:
ordercontext.infrastructure
- OrderController
- OrderMapper
- EmailSender
- RabbitEventBus
ordercontext.application
- OrderAppService
- NotificationService
- EventBus
ordercontext.domain
- OrderRepository
- PlaceOrderService
- Order
- Notification
- OrderConfirmed
- NotificationComposer
- OrderConfirmedComposer
第二种方案则将“上层对下层的调用”改为“下层对上层的通知”,即前面讲解层之间协作时所谓“自底向上”的通信问题,这就需要在领域层为订单业务定义 OrderEventPublisher 接口。当满足某个条件时通过它在领域层发布事件这个事件即所谓“领域事件Domain Event”。如果我们将建模的视角切换到以“事件”为中心则意味着领域服务在下订单完成后需要分别发布 NotificationComposed 与 OrderConfirmed 事件,并由应用层的 OrderEventHandler 作为各自事件的订阅者。这里的前提是:发送邮件与异步发送通知属于应用逻辑的一部分。
我们需要先在领域层定义发布者接口:
package practiceddd.ecommerce.ordercontext.domain;
public interface OrderEventPublisher {
void publish(NotificationComposed event);
void publish(OrderConfirmed event);
}
实现 OrderEventPublisher 接口的类放在应用层:
package practiceddd.ecommerce.ordercontext.application;
import practiceddd.ecommerce.ordercontext.domain.OrderEventPublisher;
import practiceddd.ecommerce.ordercontext.domain.NotificationComposed;
import practiceddd.ecommerce.ordercontext.domain.Notification;
import practiceddd.ecommerce.ordercontext.domain.OrderConfirmed;
public class OrderEventHandler implements OrderEventPublisher {
private NotificationService notificationService;
private EventBus eventBus;
public OrderEventHandler(NotificationService notificationService, EventBus eventBus) {
this.notificationService = notificationService;
this.eventBus = eventBus;
}
public void publish(NotificationComposed event) {
notificationService.send(event.notification());
}
public void publish(OrderConfirmed event) {
eventBus.publish(event);
}
}
应用层的应用服务则修改为:
package practiceddd.ecommerce.ordercontext.application;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
import practiceddd.ecommerce.ordercontext.domain.Order;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
@Service
public class OrderAppService {
@Autowired
private PlaceOrderService placeOrderService;
@Autowired
private NotificationService notificationService;
@Autowired
private EventBus eventBus;
public void placeOrder(Order order) {
try {
placeOrderService.register(new OrderEventHandler(notificationService, eventBus));
placeOrderService.execute(order);
} catch (InvalidOrderException ex) {
throw new ApplicationException(ex.getMessage());
}
}
}
领域服务修改为:
package practiceddd.ecommerce.ordercontext.domain;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
@Service
public class PlaceOrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private NotificationComposer notificationComposer;
private OrderEventPublisher publisher;
public void register(OrderEventPublisher publisher) {
this.publisher = publisher;
}
public void execute(Order order) {
if (!order.isValid()) {
throw new InvalidOrderException(String.format("The order with id %s is invalid.", order.id()));
}
orderRepository.save(order);
fireNotificationComposedEvent(order);
fireOrderConfirmedEvent(order);
}
private void fireNotificationComposedEvent(Order order) {
Notification notification = notificationComposer.compose(order);
publisher.publish(new NotificationComposed(notification));
}
private void fireOrderConfirmedEvent(Order order) {
publisher.publish(new OrderConfirmed(order));
}
}
倘若采用这种方案,则代码结构如下所示:
ordercontext.infrastructure
- OrderController
- OrderMapper
- EmailSender
- RabbitEventBus
ordercontext.application
- OrderAppService
- NotificationService
- EventBus
- OrderEventHandler
ordercontext.domain
- OrderRepository
- PlaceOrderService
- NotificationComposer
- OrderEventPublisher
- Order
- OrderConfirmed
- NotificationComposed
第三种方案需要重新分配 NotificationService 与 EventBus将这两个抽象接口放到单独的一个名为 interfaces 的包中,这个 interfaces 包既不属于应用层,又不属于领域层。在后面讲解代码模型时,我会解释这样设计的原因,详细内容请移步阅读后面的章节。
通过这样的职责分配后,业务逻辑发生了转移,发送邮件与异步发送通知的调用不再放到应用服务 OrderAppService 中,而是封装到了 PlaceOrderService 领域服务。这时,应用服务 OrderAppService 的实现也变得更加简单。看起来,修改后的设计似乎更符合领域驱动分层架构对应用层的定义,即“应用层是很薄的一层,不包含业务逻辑”。这里的前提是:发送邮件与异步发送通知属于业务逻辑的一部分。
应用服务的定义如下所示:
package practiceddd.ecommerce.ordercontext.application;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.domain.PlaceOrderService;
import practiceddd.ecommerce.ordercontext.domain.Order;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
@Service
public class OrderAppService {
@Autowired
private PlaceOrderService placeOrderService;
public void placeOrder(Order order) {
try {
placeOrderService.execute(order);
} catch (InvalidOrderException | Exception ex) {
throw new ApplicationException(ex.getMessage());
}
}
}
不过,领域服务就变得不太纯粹了:
package practiceddd.ecommerce.ordercontext.domain;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import practiceddd.ecommerce.ordercontext.interfaces.NotificationService;
import practiceddd.ecommerce.ordercontext.interfaces.EventBus;
import practiceddd.ecommerce.ordercontext.domain.exceptions.InvalidOrderException;
@Service
public class PlaceOrderService {
@Autowired
private NotificationService notificationService;
@Autowired
private EventBus eventBus;
@Autowired
private OrderRepository orderRepository;
@Autowired
private NotificationComposer notificationComposer;
public void execute(Order order) {
if (!order.isValid()) {
throw new InvalidOrderException(String.format("The order with id %s is invalid.", order.id()));
}
orderRepository.save(order);
notificatonService.send(notificationComposer.compose(order));
eventBus.publish(new OrderConfirmed(order));
}
}
代码结构如下所示:
ordercontext.infrastructure
- OrderController
- OrderMapper
- EmailSender
- RabbitEventBus
ordercontext.application
- OrderAppService
ordercontext.interfaces
- NotificationService
- EventBus
ordercontext.domain
- OrderRepository
- PlaceOrderService
- Order
- OrderConfirmed
- Notification
- NotificationComposer
这三个方案该如何选择?根本的出发点在于你对业务逻辑和应用逻辑的认知,进而是你对领域服务与应用服务的认知,这些内容,就留待战术设计部分来讨论。由于并不存在绝对完美的正确答案,因此我的建议是在满足功能需求与松散耦合的前提下,请尽量选择更简单的方案。

View File

@ -0,0 +1,106 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
026 限界上下文与架构
作为领域驱动战略设计的重要元素,限界上下文对领域驱动架构有着直接的影响。在领域驱动的架构设计过程中,识别限界上下文与上下文映射都是一个重要的过程。限界上下文可以作为逻辑架构与物理架构的参考模型,而上下文映射则非常直观地体现了系统架构的通信模型。
限界上下文的架构范围
这里,我需要再一次澄清 Eric Evans 提出的“限界上下文”概念:限界上下文究竟是仅仅针对领域模型的边界划分,还是对整个架构(包括基础设施层以及需要使用的外部资源)垂直方向的划分? 正如前面对 Eric Evans 观点的引用,他在《领域驱动设计》一书中明确地指出:“根据团队的组织、软件系统的各个部分的用法以及物理表现(代码和数据库模式等)来设置模型的边界”,显然,限界上下文不仅仅作用于领域层和应用层,它是架构设计而非仅仅是领域设计的关键因素。
限界上下文体现的是一个垂直的架构边界,主要针对后端架构层次的垂直切分。例如,订单上下文的内部就包含了应用层、领域层和基础设施层,每一层的模块都是面向业务进行划分,甚至可能是一一对应的。
对于基础设施层需要访问的外部资源,以及为了访问它需要重用的框架或平台,与技术决策和选型息息相关,仍然属于架构设计的考量范围,但它们不属于限界上下文的代码模型。例如,订单上下文需要访问数据库和消息队列。在技术决策上,我们需要确定是选择 NoSQL 数据库还是关系数据库,消息队列是采用 Pull 模式还是 Push 模式。在技术选型上,我们需要确定具体是哪一种数据库和消息队列中间件,同时还需要确定访问它们的框架。对资源的规划与设计也属于限界上下文的设计范围,例如,如何设计数据表、如何规划消息队列的主题。在进行这一系列的技术选型和决策时,依据的其实是该限界上下文的业务场景与质量属性,这些架构活动自然就属于该限界上下文的范畴。我们还需要决定框架的版本,这些框架并不属于系统的代码库,但需要考虑它们与限界上下文代码模型的集成、构建与部署。
限界上下文的通信边界
我们对整个业务系统按照限界上下文进行了划分。划分时,限界上下文之间是否为进程边界隔离,直接影响架构设计。此为限界上下文的通信边界,以进程为单位分为进程内与进程间两种边界。之所以这么分类,是因为进程内与进程间在如下方面存在迥然不同的处理方式:
通信
消息的序列化
资源管理
事务与一致性处理
部署
除此之外,通信边界的不同还影响了系统对各个组件(服务)的重用方式与共享方式。
进程内的通信边界
若限界上下文之间为进程内的通信方式则意味着在运行时它们的代码模型都运行在同一个进程中可以通过实例化的方式重用领域模型或其他层次的对象。即使都属于进程内通信限界上下文的代码模型Code Model仍然存在两种级别的设计方式。以 Java 为例,归纳如下。
命名空间级别通过命名空间进行界定所有的限界上下文其实都处于同一个模块Module编译后生成一个 Jar 包。
模块级别:在命名空间上是逻辑分离的,不同限界上下文属于同一个项目的不同模块,编译后生成各自的 Jar 包。这里所谓的“模块”,在 Java 代码中也可以创建为 Jigsaw 的 module。
这两种级别的代码模型仅仅存在编译期的差异,后者的解耦会更加彻底,倘若限界上下文的划分足够合理,也能提高它们对变化的应对能力。例如,当限界上下文 A 的业务场景发生变更时,我们可以只修改和重编译限界上下文 A 对应的 Jar 包,其余 Jar 包并不会受到影响。由于它们都运行在同一个 Java 虚拟机中,意味着当变化发生时,整个系统需要重新启动和运行。
即使处于同一个进程的边界我们仍需重视代码模型的边界划分因为这种边界隔离有助于整个系统代码结构变得更加清晰。限界上下文之间若采用进程内通信则彼此之间的协作会更加容易、更加高效。然而正所谓越容易重用就越容易产生耦合。编写代码时我们需要谨守这条无形的逻辑边界时刻注意不要逾界并确定限界上下文各自对外公开的接口避免它们之间产生过多的依赖。此时防腐层ACL就成了抵御外部限界上下文变化的最佳场所。一旦系统架构需要将限界上下文调整为进程间的通信边界这种“各自为政”的设计与实现能够更好地适应这种演进。
以第 10 课介绍的项目管理系统为例,假设项目上下文与通知上下文之间的通信为进程内通信,当项目负责人将 Sprint Backlog 成功分配给团队成员之后,系统将发送邮件通知该团队成员。这个职责由项目上下文的 AssignSprintBacklogService 领域服务承担,而发送通知的职责则由通知上下文的 NotificationAppService 应用服务承担。考虑到未来限界上下文通信边界的变化,我们就不能直接在 AssignSprintBacklogService 服务中实例化 NotificationAppService 对象,而是在项目上下文中定义通知服务的接口 NotificationService并由 NotificationClient 去实现这个接口它们扮演的就是防腐层的作用。AssignSprintBacklogService 服务依赖该防腐层的接口,并将具体实现通过依赖注入。这个协作过程如下面的时序图所示:
倘若在未来需要将通知上下文分离为进程间的通信边界,这种变动将只会影响到防腐层的实现,作为 NotificationService 服务的调用者,并不会受到这一变化的影响。
采用进程内通信的系统架构属于单体Monolithic架构所有限界上下文部署在同一个进程中因此不能针对某一个限界上下文进行水平伸缩。当我们需要对限界上下文的实现进行替换或升级时也会影响到整个系统。即使我们守住了代码模型的边界耦合仍然存在导致各个限界上下文的开发互相影响团队之间的协调成本也随之而增加。
进程间的通信边界
倘若限界上下文之间的通信是跨进程的,则意味着限界上下文是以进程为边界。此时,一个限界上下文就不能直接调用另一个限界上下文的方法,而是要通过分布式的通信方式。
当我们将一个限界上下文限定在进程边界内时,并不足以决定领域驱动架构的设计质量。我们还需要将这个边界的外延扩大,考虑限界上下文需要访问的外部资源,这就产生了两种不同风格的架构:
数据库共享架构
零共享架构
数据库共享架构
数据库共享架构其实是一种折中的手段。在考虑限界上下文划分时,分开考虑代码模型与数据库模型,就可能出现代码的运行是进程分离的,数据库却共享彼此的数据,即多个限界上下文共享同一个数据库。由于没有分库,在数据库层面就可以更好地保证事务的 ACID。这或许是该方案最有说服力的证据但也可以视为是对“一致性”约束的妥协。
数据库共享的问题在于数据库的变化方向与业务的变化方向并不一致,这种不一致性体现在两方面,具体如下。
耦合:虽然限界上下文的代码模型是解耦的,但在数据库层面依然存在强耦合关系。
水平伸缩:部署在应用服务器的应用服务可以根据限界上下文的边界单独进行水平伸缩,但在数据库层面却无法做到。
根据 Netflix 团队提出的微服务架构最佳实践其中一个最重要特征就是“每个微服务的数据单独存储”但是服务的分离并不绝对代表数据应该分离。数据库的样式Schema与领域模型未必存在一对一的映射关系。在对数据进行分库设计时如果仅仅站在业务边界的角度去思考可能会因为分库的粒度太小导致不必要的跨库关联。因此我们可以将“数据库共享”模式视为一种过渡方案。如果没有想清楚微服务的边界就不要在一开始设计微服务时就直接将数据彻底分开而应采用演进式的设计。
为了便于在演进设计中将分表重构为分库,从一开始要注意避免在分属两个限界上下文的表之间建立外键约束关系。某些关系型数据库可能通过这种约束关系提供级联更新与删除的功能,这种功能反过来会影响代码的实现。一旦因为分库而去掉表之间的外键约束关系,需要修改的代码太多,会导致演进的成本太高,甚至可能因为某种疏漏带来隐藏的 Bug。
如果设计数据表时没有外键约束关系,可能在当前增加了开发成本,却为未来的演进打开了方便之门。例如,在针对某手机品牌开发的舆情分析系统中,危机查询服务提供对识别出来的危机进行查询。查询时,需要通过 userID 获得危机处理人、危机汇报人的详细信息。左图为演进前直接通过数据库查询的方式,右图则切断了这种数据库耦合,改为服务调用的方式:
数据库共享架构也可能是一种“反模式”。当两个分处不同限界上下文的服务需要操作同一张数据表(这张表被称之为“共享表”)时,就传递了一个信号,即我们的设计可能出现了错误。
遗漏了一个限界上下文,共享表对应的是一个被重用的服务:买家在查询商品时,商品服务会查询价格表中的当前价格,而在提交订单时,订单服务也会查询价格表中的价格,计算当前的订单总额;共享价格数据的原因是我们遗漏了价格上下文,通过引入价格服务就可以解除这种不必要的数据共享。
职责分配出现了问题,操作共享表的职责应该分配给已有的服务:舆情服务与危机服务都需要从邮件模板表中获取模板数据,然后再调用邮件服务组合模板的内容发送邮件;实际上从邮件模板表获取模板数据的职责应该分配给已有的邮件服务。
共享表对应两个限界上下文的不同概念:仓储上下文与订单上下文都需要访问共享的产品表,但实际上这两个上下文需要的产品信息并不相同,应该按照限界上下文的边界分开为各自关心的产品信息建表。
为什么会出现这三种错误的设计?根本原因在于我们没有通过业务建模,而是在数据库层面隐式地进行建模,因而在代码中没有体现正确的领域模型,从而导致了数据库的耦合或共享。遵循领域驱动设计的原则,我们应该根据领域逻辑去识别限界上下文。
零共享架构
当我们将两个限界上下文共享的外部资源彻底斩断后,就成为了零共享架构。例如,前面介绍的舆情分析系统,在去掉危机查询对用户表的依赖后,就演进为零共享架构。如下图所示,危机分析上下文与用户上下文完全零共享:
这是一种限界上下文彻底独立的架构风格,它保证了边界内的服务、基础设施乃至于存储资源、中间件等其他外部资源的完整性与独立性,最终形成自治的微服务。这种架构的表现形式为:每个限界上下文都有自己的代码库、数据存储以及开发团队,每个限界上下文选择的技术栈和语言平台也可以不同,限界上下文之间仅仅通过限定的通信协议和数据格式进行通信。
此时的限界上下文形成了一个相对自由的“独立王国”。从北向网关的 Controller 到应用层,从应用层到领域层的领域模型,再到南向网关对数据库的访问实现,进而到数据库的选型都可以由当前限界上下文独立做主。由于它们是“零共享”的,使得它们彼此之间可以独立演化,在技术选型上也可以结合自己上下文的业务场景做出“恰如其分”的选择。譬如说,危机分析需要存储大规模的非结构化数据,同时业务需要支持对危机数据的全文本搜索,我们选择了 ElasticSearch 作为持久化的数据库。考虑到开发的高效以及对 JSON 数据的支持,我们选择了 Node.js 为后端开发框架。至于用户上下文,数据量小,结构规范,采用传统的基于关系型数据库的架构会更简单更适合。二者之间唯一的耦合就是危机分析通过 HTTP 协议访问上游的用户服务,根据传入的 userID 获得用户的详细信息。
彻底分离的限界上下文变得小而专,使得我们可以很好地安排遵循 2PTs 规则的小团队去治理它。然而,这种架构的复杂度也不可低估。限界上下文之间的通信是跨进程的,我们需要考虑通信的健壮性。数据库是完全分离的,当需要关联之间的数据时,需得跨限界上下文去访问,无法享受数据库自身提供的关联福利。由于每个限界上下文都是分布式的,如何保证数据的一致性也是一件棘手的问题。当整个系统都被分解成一个个可以独立部署的限界上下文时,运维与监控的复杂度也随之而剧增。

View File

@ -0,0 +1,120 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
027 限界上下文对架构的影响
通信边界对架构的影响
限界上下文的通信边界会对系统的架构产生直接的影响,在此之前,我们需要理清几个和边界有关的概念。如前所述,我提出了限界上下文的通信边界的概念,并将其分为进程内通信与进程间通信两种方式。在 Toby Clemson 给出的微服务架构中,则将逻辑边界视为整个微服务的边界,而将微服务代码模型中的所有模块视为在同一个网络边界内。但我认为在引入了虚拟化以及容器技术后,仍将这种边界描述为网络边界似乎并不准确,因此我以进程边界来表示前面提到的通信边界。
显然,倘若限界上下文之间采用进程间通信,则每个限界上下文就可以认为是一个微服务——对于微服务,我更愿意用进程边界来界定代码模型的部署与运行。
无论是网络边界,还是进程边界,都可以视为物理边界;而代码模型中对于层以及模块的划分,则属于逻辑边界的范畴。逻辑边界有时候会和物理边界重合,但这仅仅是针对代码模型而言。一个系统多数情况下都会访问其物理边界之外的外部资源,如此看来,一个系统的逻辑边界往往要大于物理边界。
在进行架构设计时,我们往往会将整个系统的架构划分为多个不同的视图,其中最主要的视图就是逻辑视图和物理视图,这是我们看待系统的两种不同视角。前者关注代码结构、层次以及职责的分配,后者关注部署、运行以及资源的分配,这两种视图都需要考虑限界上下文以及它们之间的协作关系。在考虑逻辑视图时,我们会为限界上下文履行的职责所吸引,同时又需得关注它们之间的协作,此时,就该物理视图粉墨登场了。若两个限界上下文的代码模型属于同一个物理边界,就是部署和运行在同一个进程中的好哥俩儿,调用方式变得直接,协作关系较为简单,我们只需要在实现时尽可能维护好逻辑边界即可。如果限界上下文代码模型的逻辑边界与物理边界完全重叠,要考虑的架构要素就变得复杂了。
对于跨进程边界进行协作的限界上下文,我建议为其绘制上下文映射,并通过六边形架构来确定二者之间的通信端口与通信协议。上游限界上下文公开的接口可以是基于 HTTP 的 REST 服务,也可以通过 RPC 访问远程对象,又或者利用消息中间件传递消息。选择的通信协议不同,传递的消息格式以及序列化机制也不同,为下游限界上下文建立的客户端也不相同。由于这种协作关系其实是一种分布式调用,自然存在分布式系统与身俱来的缺陷,例如,网络总是不可靠,维护数据一致性要受到 CAP 原则的约束。这时就需要考虑服务调用的熔断来及时应对故障避免因单一故障点带来整个微服务架构的连锁反应。我们还需要权衡数据一致性问题若不要求严格的数据一致性则可以引入最终一致性BASE如采用可靠事件模式、补偿模式或者 TCCTry-Confirm-Cancel模式等。当然我们还需要考虑安全、部署和运维等诸多与分布式系统有关的问题这些问题已经超出了本课程讨论的范围这里就略过不提了。
限界上下文、六边形架构与微服务
如前所述,倘若我们将单个限界上下文代码模型的边界视为物理边界,则可以认为一个限界上下文就是一个微服务。而在前面介绍六边形架构时,我也提到该架构模式外部的六边形边界实则也是物理边界。基于这些前提,我们得出结论:
一个限界上下文就是一个六边形,限界上下文之间的通信通过六边形的端口进行;
一个微服务就是一个六边形,微服务之间的协作就是限界上下文之间的协作。
显然,在将限界上下文的代码模型边界视为物理边界时,限界上下文、六边形与微服务之间就成了“三位一体”的关系。我们可以将三者的设计原则与思想结合起来,如下图所示:
该图清晰地表达了这种“三位一体”的关系。
限界上下文即微服务:我们可以利用领域驱动设计对限界上下文的定义,以及根据前述识别限界上下文的方法来设计微服务。
微服务即限界上下文:运用微服务设计原则,可以进一步甄别限界上下文的边界是否合理,对限界上下文进行进一步的演化。
微服务即六边形:深刻体会微服务的“零共享架构”,并通过六边形架构来表达微服务。
限界上下文即六边形:运用上下文映射来进一步探索六边形架构的端口与适配器角色。
六边形即限界上下文:通过六边形架构的端口确定限界上下文之间的集成关系。
我们试以电商系统的购物流程来说明这种“三位一体”的关系。首先,我们通过领域场景分析的用例图来分析该购物流程:
通过对各个用例的语义相关性与功能相关性,结合这些用例的业务能力,可以确定用例的边界。当我们为这些边界进行命名时,就初步获得了如下六个限界上下文:
Product Context
Basket Context
Order Context
Inventory Context
Payment Context
Notification Context
结合购买流程电商系统还需要用到第三方物流系统对商品进行配送这个物流系统可以认为是电商系统的外部系统External Service。如果这六个限界上下文之间采用跨进程通信实际上就是六个微服务它们应该单独部署在不同节点之上。现在我们需要站在微服务的角度对其进行思考。需要考虑的内容包括如下。
每个微服务是如何独立部署和运行的?如果我们从运维角度去思考微服务,就可以直观地理解所谓的“零共享架构”到底是什么含义。如果我们在规划系统的部署视图时,发现微服务之间在某些资源存在共用或纠缠不清的情况,就说明微服务的边界存在不合理之处,换言之,也就是之前识别限界上下文存在不妥。
微服务之间是如何协作的?这个问题牵涉到通信机制的决策、同步或异步协作的选择、上游与下游服务的确定。我们可以结合上下文映射与六边形架构来思考这些问题。上下文映射帮助我们确定这种协作模式,并在确定了上下游关系后,通过六边形架构来定义端口。
现在我们可以将六边形架构与限界上下文结合起来即通过端口确定限界上下文之间的协作关系绘制上下文映射。如果采用客户方—供应商开发模式则各个限界上下文六边形的端口就是上游Upstream简称 U或下游Downstream简称 D。由于这些限界上下文都是独立部署的微服务因此它们的上游端口应实现为 OHS 模式(下图以绿色端口表示),下游端口应实现为 ACL 模式(下图以蓝色端口表示):
每个微服务都是一个独立的应用我们可以针对每个微服务规划自己的分层架构进而确定微服务内的领域建模方式。微服务的协作也有三种机制分别为命令、查询和事件。Ben Stopford 在文章 Build Services on a Backbone of Events 中总结了这三种机制,具体如下。
命令:是一个动作,是一个要求其他服务完成某些操作的请求,它会改变系统的状态,命令会要求响应。
查询:是一个请求,查看是否发生了什么事。重要的是,查询操作没有副作用,它们不会改变系统的状态。
事件:既是事实又是触发器,用通知的方式向外部表明发生了某些事。
发出命令或查询请求的为下游服务而服务的定义则处于上游。如上图所示我以菱形端口代表“命令”矩形端口代表“查询”这样就能直观地通过上下文映射以及六边形的端口清晰地表达微服务的服务定义以及服务之间的协作方式。例如Product Context 同时作为 Basket Context 与 Order Context 的上游限界上下文其查询端口提供的是商品查询服务。Basket Context 作为 Order Context 的上游限界上下文,其命令端口提供了清除购物篮的命令服务。
如果微服务的协作采用事件机制,则上下文映射的模式为发布/订阅事件模式。这时限界上下文之间的关系有所不同我们需要识别在这个流程中发生的关键事件。传递关键事件的就是六边形的端口具体实现为消息队列适配器则负责发布事件。于是系统的整体架构就演变为以事件驱动架构Event-Driven ArchitectureEDA风格构建的微服务系统。Vaughn Vernon 在《实现领域驱动设计》一书中使用六边形架构形象地展现了这一架构风格。
六边形之间传递的三角形就是导致限界上下文切换的关键事件在领域驱动设计中作为领域事件Domain Event被定义在领域层。为了与限界上下文内部传递的领域事件区分开我们可以名其为“关键领域事件”又或者称为“应用事件”它仍然属于领域模型中的一部分。在前面所示的上下文映射中我们可以用三角形端口来代表“事件”事件端口所在的限界上下文为发布者该事件对应的下游端口则为订阅者。然而当我们采用“事件”的协作机制时上下文映射中的上下游语义却发生了变化原来作为“命令”或“查询”提供者的上游却成为了“事件”机制下处于下游的订阅者。以购物篮为例“清除购物篮”命令服务被定义在 Basket Context 中。当提交订单成功后Order Context 就会发起对该服务的调用。倘若将“提交订单”视为一个内部命令Command在订单被提交成功后就会触发 OrderConfirmed 事件此时Order Context 反而成为了该事件的发布者Basket Context 则会订阅该事件,一旦侦听到该事件触发,就会在 Basket Context 内部执行“清除购物篮”命令。显然,“清除购物篮”不再作为服务发布,而是在事件的 handler 中作为内部功能被调用。
采用“事件”协作机制会改变我们习惯的顺序式服务调用形式,整个调用链会随着事件的发布而产生跳转,尤其是暴露在六边形端口的“关键事件”,更是会产生跨六边形(即限界上下文)的协作。仍以电商系统的购买流程为例,我们只考虑正常流程。在 Basket Context 中,一旦购物篮中的商品准备就绪,买家就会请求下订单,此时开始了事件流。
Basket Context 发布 OrderRequested 事件Order Context 订阅该事件,然后执行提交订单的流程。
Order Context 验证订单,并发布 InventoryRequested 事件,要求验证订单中购买商品的数量是否满足库存要求。
Inventory Context 订阅此事件并对商品库存进行检查,倘若检查通过,则发布 AvailabilityValidated 事件。
Order Context 侦听到 AvailabilityValidated 事件后,验证通过,发布 OrderValidated 事件从而发起支付流程。
Payment Context 响应 OrderValidated 事件,在支付成功后发布 PaymentProcessed 事件。
Order Context 订阅 PaymentProcessed 事件,确认支付完成进而发布 OrderConfirmed 事件。
Basket Context、Notification Context 与 Shipment Context 上下文都将订阅该事件。Basket Context 会清除购物篮Notification Context 会发起对买家和卖家的通知,而 Shipment Context 会发起配送流程,在交付商品给买家后,发布 ShipmentDelivered 事件并被 Order Context 订阅。
整个协作过程如下图所示(图中的序号对应事件流的编号):
与订单流程相关的事件包括:
OrderRequested
InventoryRequested
AvailabilityValidated
OrderValidated
PaymentProcessed
OrderConfirmed
ShipmentDelivered
我们注意到这些事件皆以“过去时态”命名这是因为事件的本质是“事实Fact意味着它是过去发生的且不可变更的数据代表了某种动作的发生并以事件的形式留下了足迹。
正如前面给出的事件驱动架构所示事件的发布者负责触发输出事件Outgoing Event事件的订阅者负责处理输入事件Incoming Event它们作为六边形的事件适配器也就是我所说的网关被定义在基础设施层。事件适配器的抽象则被定义在应用层。假设电商系统选择 Kafka 作为事件传递的通道我们就可以为不同的事件类别定义不同的主题Topic。此时Kafka 相当于是连接微服务之间进行协作的事件总线Event Bus。Ben Stopford 将采用这种机制实现的微服务称为“事件驱动服务Event Driven Services”。
通过电商系统的这个案例,清晰地为我们勾勒出限界上下文、六边形与微服务“三位一体”的设计脉络,即它们的设计思想、设计原则与设计方法是互相促进互相融合的。在架构设计层面上,三者可谓浑然一体。

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
028 领域驱动设计的代码模型
理解了限界上下文和分层架构的本质,要确认系统的代码模型自然也就水到渠成。提醒注意,没有必要要求每个团队都遵守一套代码模型,但在同一个项目中,代码模型应作为架构规范要求每个团队成员必须遵守。当然,在遵守规范的同时,每个人需要理解如此划分代码模型的意义所在、价值所在。
遵循领域驱动设计思想的代码模型
结合领域驱动分层架构设计思想,通过引入整洁架构与六边形架构以及上下文映射等设计原则与模式,我们对层、层之间协作、跨限界上下文之间的协作已经有了深入的理解。当我们考虑限界上下文的代码模型时,需要考虑纵向架构除前端之外的所有层次或模块。故而在代码模型设计因素中,需要考虑层与模块之间的职责分离与松散耦合,同时还必须将整个限界上下文作为基本设计单元,照顾到限界上下文之间的协作关系。基于这样的设计因素,结合我自己的项目经验,给出了如下代码模型推荐:
- application
- interfaces
- domain
- repositories
- gateways
- controllers
- persistence
- mq
- client
- ...
以下是对代码结构的说明。
application对应了领域驱动设计的应用层主要内容为该限界上下文中所有的应用服务。
interfaces对 gateways 中除 persistence 之外的抽象包括访问除数据库之外其他外部资源的抽象接口以及访问第三方服务或其他限界上下文服务的抽象接口。从分层架构的角度讲interfaces 应该属于应用层,但在实践时,往往会遭遇领域层需要访问这些抽象接口的情形,单独分离 出 interfaces非常有必要。
domain对应了领域驱动设计的领域层但是我将 repositories 单独分了出来,目的是为了更好地体现它在基础设施层扮演的与外部资源打交道的网关语义。
repositories代表了领域驱动设计中战术设计阶段的资源库皆为抽象类型。如果该限界上下文的资源库并不复杂可以将 repositories 合并到 domain 中。
gateways对应了领域驱动设计的基础设施层命名为 gateways是为了更好地体现网关的语义其下可以视外部资源的集成需求划分不同的包。其中controllers 相对特殊它属于对客户端提供接口的北向网关等同于上下文映射中“开放主机服务OHS”的概念。如果为了凸显它的重要性可以将 controllers 提升到与 application、domain、gateways 同等层次。我之所以将其放在 gateways 之下还是想体现它的网关本质。persistence 对应了 repositories 抽象,至于其余网关,对应的则是 interfaces 下的抽象,包括消息队列以及与其他限界上下文交互的客户端。例如,通过 http 通信的客户端。其中client 包下的实现类与 interfaces 下的对应接口组合起来等同于上下文映射中“防腐层ACL”的概念。
我们看到,这里给出的代码结构并未严格按照领域驱动设计的分层架构来划分,我们需要把握以下内容。
分层架构的层并非编程语言可以限定的,因此它只是一种设计概念,最后都需要映射到模块或包的概念上。
无论代码结构是否表达了层的概念,都需要充分理解分层的意义,并使得整个代码结构在架构上要吻合分层架构的理念。
每个模块或包都是单一职责的设计,在整个代码模型中扮演着不同的角色,有的对应了分层架构的层,有的代表了领域驱动设计的设计要素,有的则是为了保证架构的松散耦合。
如果不考虑 Repository 在领域驱动设计中的特殊性,而仅仅将其视为一种网关,则上述结构中 gateways 与 interfaces 恰恰建立了一一对应的对称关系。唯有 controllers 因为不需要依赖注入的关系,没有对应在 interfaces 模块中的抽象定义。考虑到 controllers 对应上下文映射的开放主机服务OHS模式client 对应上下文映射的防腐层ACL模式我们还可以定义如下更符合领域驱动设计特色的代码模型
- application
- domain
- interfaces
- repositories
- mq
- acl
- ...
- gateways
- ohs
- persistence
- mq
- acl
- ...
代码模型中的 ohs 和 acl 不言自明,充分说明了它们在架构中发挥的作用。倘若我们在团队中明确传递这一设计知识,不仅可以让团队成员更加充分地理解“开放主机服务”与“防腐层”的意义,也更有利于保证限界上下文在整个架构中的独立性。诸如 ohs 与 acl 的命名,也可以认为是代码模型中的一种“统一语言”吧。
虽然都遵循了领域驱动设计,但限界上下文的通信边界会直接影响到代码模型的设计决策。
进程间通信的代码模型
如果限界上下文的边界是进程间通信,则意味着每个限界上下文就是一个单独的部署单元,此即微服务的意义。通常,我们希望一个微服务应该设计为单一职责的高内聚服务,然而麻雀虽小,五脏俱全,在微服务的边界范围内,我认为仍然需要为其建立分层架构。当然,由于微服务的粒度较小,它的代码模型一般采用命名空间级别的方式,整个微服务的代码模型生成一个 JAR 包即可。
架构的设计需要“恰如其分”在不同的微服务中各自的领域逻辑复杂程度亦不尽相同故而不必严格遵循领域驱动设计的规范。Martin Fowler 在《企业应用架构模式》一书中针对不同复杂度的领域总结了三种不同的领域建模模式包括事务脚本Transaction Script、表模块Table Module或领域模型Domain Model。在物理隔离的限界上下文内部我们可以有针对性地选择不同的领域模型。Scott Millett 的著作《Patterns、Principles and Practices of Domain-Driven Design》就此给出了如下图所示的架构
领域模型不同代码结构也会受到影响。例如选择了事务脚本领域模型就不一定要规避贫血模型依赖注入也就未必成为必选项了Repositories 的抽象意义也有所不同。既然本课程讲解领域驱动设计,因此这里主要探讨领域模型的建模方式,即领域驱动战术设计所建议的模式与原则。
还记得前面在讲解层次的职责与协作关系给出的下订单案例吗?当我们选择第三种方案时,给出的代码模型如下所示:
ordercontext.infrastructure
- OrderController
- CreateOrderRequest
- OrderMapper
- EmailSender
- RabbitEventBus
ordercontext.application
- OrderAppService
ordercontext.interfaces
- NotificationService
- EventBus
ordercontext.domain
- OrderRepository
- PlaceOrderService
- Order
- OrderConfirmed
- Notification
- NotificationComposer
现在,为了更好地体现限界上下文之间的协作,我们将本例中的邮件通知放到一个单独的限界上下文 Notification Context 中。Order Context 与 Notification Context 之间采用了进程间通信,则遵循前面的建议,修改代码模型为:
ordercontext
- gateways
- controllers
- OrderController
- messages
- CreateOrderRequest
- persistence
- OrderMapper
- client
- NotificationClient
- mq
- RabbitEventBus
- application
- OrderAppService
- interfaces
- client
- NotificationService
- SendNotificationRequest
- mq
- EventBus
- domain
- PlaceOrderService
- Order
- OrderConfirmed
- Notification
- NotificationComposer
- repositories
- OrderRepository
notificationcontext
- controllers
- NotificationController
- messages
- SendNotificationRequest
- application
- NotificationAppService
- interfaces
- EmailSender
- domain
- NotificationService
- Destination
- Message
- gateways
- JavaMailSender
与之前的代码模型比较,现在的代码模型去掉了 infrastructure 的概念,改以各种 gateway 来表示。同时,还单独定义了 interfaces 模块,包含各种网关对应的抽象接口。
代码模型需要考虑 Order Context 与 Notification Context 之间的跨进程协作。设计的目标是确保彼此之间的解耦合,此时可以引入上下文映射的开放主机服务模式与防腐层模式,同时还应避免遵奉者模式,即避免重用上游上下文的领域模型。因此,针对邮件通知功能,在 Order Context 中定义了调用 Notification Context 上下文服务的客户端 NotificationClient 与对应的抽象接口 NotificationService。这两个类型合起来恰好就是针对 Notification Context 的防腐层。Notification Context 定义了 NotificationController相当于是该限界上下文的开放主机服务。
Notification Context 定义了自己的领域模型,包括 Destination 与 Message。同时在 controllers 中定义了服务消息 SendNotificationRequestOrder Context 则针对通知服务的调用,定义了自己的领域模型 Notification以及匹配服务调用的请求消息对象 SendNotificationRequest。由于 Order Context 与 Notification Context 属于两个不同的微服务,因此在 Order Context 微服务中 gateways/client 的 NotificationClient 会发起对 NotificationController 的调用,这种协作方式如下图所示:
由于限界上下文之间采用进程间通信,因此在 Notification Context 中,提供开放主机服务是必须的。倘若 NotificationController 以 RESTful 服务实现,则在 Order Context 发起对 RESTful 服务的调用属于基础设施的内容,因而必须定义 NotificationService 接口来隔离这种实现机制,使其符合整洁架构思想。
进程内通信的代码结构
如果限界上下文之间采用进程内通信,需要注意如何在代码模型中体现限界上下文的边界,更关键的则是要考虑两个处于相同进程中的限界上下文彼此之间该如何协作。如下是针对各种设计因素的考量。
简单:在下游限界上下文的领域层直接实例化上游限界上下文的领域类。
解耦:在下游限界上下文的领域层通过上游限界上下文的接口和依赖注入进行调用。
迁移:在下游限界上下文中定义一个防腐层,而非直接调用。
清晰:要保证领域层代码的纯粹性,应该避免在当前限界上下文中依赖不属于自己的代码模型。
综合考虑,如果确有迁移可能,且架构师需要追求一种纯粹的清晰架构,可以考虑在 interface 中定义自己的服务接口,然后在 gateway/client 中提供一个适配器,在实现该接口的同时,调用上游限界上下文的服务,无论这个服务是领域服务还是应用服务,甚至也可以是领域层的领域对象。因为这个调用的事实已经被 interface 中的接口隔离了。
仍然以下订单场景为例,但此时的 Notification Context 与 Order Context 采用进程内通信,则这种协作方式如下图所示:
与进程间通信的唯一区别在于NotificationClient 不再通过跨进程调用的方式发起对 RESTful 服务的调用,即使在 Notification Context 中定义了这样的开放主机服务。如上图所示NotificationClient 直接通过实例化的方式调用了 Notification Context 应用层的 NotificationAppService。这是在 Order Context 中,唯一与 Notification Context 产生了依赖的地方。
如此看来,即使限界上下文采用进程内通信,也仅仅是封装在防腐层中发起调用的实现有所不同,即前面例子中的 NotificationClient而这其实并不影响代码模型。因而无论是进程间通信还是进程内通信我们设计的代码模型其实是一致的并不受通信边界的影响。之所以这样设计理由有二具体如下。
通信边界的划分是物理意义,代码模型的划分是逻辑意义,二者互相并不影响。
为保证系统从单体架构向微服务架构迁移,应保证代码结构不受架构风格变化的影响。
例如,假设本书的域名为 practiceddd对于一个电商系统无论限界上下文的边界为进程间通信还是进程内通信上下文的命名空间都应该为practiceddd.ecommerce.{contextname}其下的层次则是上述提及的代码模型。例如订单上下文的命名空间为praticeddd.ecommerce.ordercontext商品上下文的命名空间为praticeddd.ecommerce.productcontext。整个系统的代码结构如下所示
- praticeddd
-ecommerce
- ordercontext
- application
- interfaces
- domain
- repositories
- gateways
- productcontext
- application
- interfaces
- domain
- repositories
- gateways
- ......
或许有人会提出疑问,如果 ordercontext 与 productcontext 或者其他限界上下文之间存在共同代码,该如何分配?首先我们要认识到,这里的所有组织单元(层、模块或包)都是围绕着领域逻辑来划分的。之所以在限界上下文之下还要分级划分,原因只是各个组织单元的关注点不同而已,至于一些公共的与外部资源有关的代码,都是系统边界之外的第三方框架或平台,这一点在前面介绍架构演进时已反复提及。
基于这样的设计前提如果两个或多个限界上下文还存在共同代码只能说明一点那就是我们之前识别的限界上下文有问题在第17课“上下文映射的团队协作模式”中我们提到的“共享内核”模式就是用来解决此类问题的一种方法。一旦提炼或发现了这个隐藏的限界上下文就应该将它单列出来与其他限界上下文享受相同的待遇即处于代码模型的相同层次然后再通过 interfaces 与 gateways/client 下的相关类配合完成限界上下文之间的协作即可。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
029 代码模型的架构决策
代码模型属于软件架构的一部分,它是设计模型的进化与实现,体现出了代码模块(包)的结构层次。在架构视图中,代码模型甚至会作为其中的一个视图,通过它来展现模块的划分,并定义运行时实体与执行视图建立联系,如下图所示:
确定软件系统的代码模型属于架构决策的一部分。在领域驱动设计背景下,代码模型的设计可以分为两个层次,具体如下。
系统层次:设计整个软件系统的代码模型。
限界上下文层次:设计限界上下文内部的代码模型。
在领域驱动设计中,限界上下文对整个系统进行了划分,以便于实现“分而治之”的架构设计思想。正如前面几课所述,我们可以将每个限界上下文视为一个自治单元,这个自治单元就像一个独立的子系统,可以有自己的架构。尤其是当我们将一个限界上下文视为一个微服务时,这种控制在边界内的独立架构就显得更加明显。上一课介绍的代码模型,其实是这样的模型设计层次。
针对整个软件系统,我们可以将这些限界上下文视为一个黑盒子。我们仅需关心限界上下文暴露的接口以及它们之间的协作关系。而对于整个软件系统,则需要保证其在架构风格上的一致性。所谓“风格”,可以参考 Roy Fielding 的定义:
风格是一种用来对架构进行分类和定义它们的公共特征的机制。每一种风格都为组件的交互提供了一种抽象并且通过忽略架构中其余部分的偶然性细节来捕获一种交互模式pattern of interation的本质特征。
Roy Fielding 对“风格”的定义突出了对架构的分类和公共特征的定义。无论是分类,还是识别公共特征,都是一种抽象。同时,定义中明确指出风格为组件的协作提供了抽象,这说明架构风格并不涉及实现细节。在架构设计时,需要找出那些稳定不变的本质特征,且这个特征与系统的目标还有需求是相匹配的。结合领域驱动设计,限界上下文以及上下文映射就是这样的一种抽象:
如果我们将限界上下文视为微服务,则该系统的架构风格就是微服务架构风格;
如果我们将上下文协作模式抽象为发布/订阅事件,则该系统的架构风格就是事件驱动架构风格;
如果在限界上下文层面将查询与命令分为两种不同的实现模式则该系统的架构风格就是命令查询职责分离CQRS架构风格。
显然,这些架构风格适应于不同的应用场景,即这些风格的选择应与系统要解决的问题域相关。为了保证整个软件系统架构设计的一致性,我们可以结合 Simon Brown 提出的 C4 模型来考虑设计元素的粒度和层次:
自上而下Simon Brown 将整个软件系统分为了四个层次分别为系统上下文System Context、容器Containers、组件Components以及类Classes这些层次的说明如下所示。
系统上下文:是最高的抽象层次,代表了能够提供价值的东西。一个系统由多个独立的容器构成。
容器:是指一个在其内部可以执行组件或驻留数据的东西。作为整个系统的一部分,容器通常是可执行文件,但未必是各自独立的进程。从容器的角度理解一个软件系统的关键在于,任何容器间的通信可能都需要一个远程接口。
组件:可以想象成一个或多个类组成的逻辑群组。组件通常由多个类在更高层次的约束下组合而成。
类:在一个面向对象的世界里,类是软件系统的最小结构单元。
在系统上下文层次,我们需要考虑的架构因素是将系统作为一个完整单元,然后思考它和用户之间的交互方式是怎样的,需要集成的外部系统有哪些,采用怎样的通信协议?若在这个层次考虑架构风格,就更容易建立架构的抽象体系。例如:
倘若我们采用微服务的架构风格,意味着包括用户和外部系统在内的客户端都需要通过 API Gateway 实现微服务的调用;
倘若采用事件驱动架构风格,意味着系统与外部系统之间需要引入消息中间件,以便于事件消息的传递;
倘若采用 CQRS 架构风格,意味着系统暴露的 API 接口需要分解为命令接口和查询接口,接口类型不同,处理模式和执行方式都不相同。
C4 模型中的容器基本等同于微服务的概念,推而广之也就代表了限界上下文的概念。其实,容器与组件之间的边界很模糊,这取决于我们对限界上下文之间通信机制的决策。不仅限于此,即使采用了微服务架构风格,我们识别出来的限界上下文亦未必一定要部署为一个微服务。它们可能为整个系统提供公共的基础功能,因而在微服务架构中实际是以公共组件的形式而存在的。
在代码模型上,这些公共组件又分为两种。
一种公共组件具有业务价值因而对应于一个限界上下文可以视为是支撑子域Supporting SubDomain或通用子域Generic SubDomain在解决方案上的体现如规则引擎、消息验证、分析算法等。那么在微服务架构风格中为何不将这样的限界上下文部署为微服务呢这实际上是基于微服务的优势与不足来做出的设计决策。
如上图所示,微服务保证了技术选择的自由、发布节奏的自由、独立升级的自由以及自由扩展硬件配置资源的自由。为了获得这些自由,付出的代价自然也不少,其中就包括分布式系统固有的复杂度、数据的一致性问题以及在部署和运维时带来的挑战。除此之外,我们还需要考虑微服务协作时带来的网络传输成本。如果我们能结合具体的业务场景考虑这些优势与不足,就可以在微服务与公共组件之间做出设计权衡。
基于我个人的经验,我认为当满足以下条件时,应优先考虑设计为微服务:
实现经常变更,导致功能需要频繁升级;
采用了完全不一样的技术栈;
对高并发与低延迟敏感,需要单独进行水平扩展;
是一种端对端的垂直业务体现(即需要与外部环境或资源协作)。
当满足以下条件时,应优先考虑设计为公共组件:
需求几乎不会变化,提供了内聚而又稳定的实现;
在与其进行协作时,需要传输大量的数据;
无需访问外部资源。
如果找不到支持微服务的绝对理由,我们应优先考虑将其设计为公共组件。
另一种公共组件并非领域的一部分仅仅提供了公共的基础设施功能如文件读写、FTP 传输、Telnet 通信等。本质上,这些公共组件属于基础设施层的模块。如果是多个限界上下文都需要重用的公共模块,甚至可以将该公共组件视为一种基础的平台或框架。这时,它不再属于限界上下文中的代码模型,而是作为整个系统的代码模型。当然,倘若我们将这种公共组件视为基础平台或框架,还可以为其建立单独的模块(或项目),放在专有的代码库中,并以二进制依赖的形式被当前软件系统所使用。
整体而言,一个典型的微服务架构通常如下图所示:
采用微服务架构风格时,诸如 Spring Cloud 之类的微服务框架事实上间接地帮我们完成了整个系统架构的代码模型。例如:
API Gateway 作为所有微服务的访问入口Euraka 或 Consul 提供的服务注册与发现,帮我们解决了微服务协作的访问功能;
Feign 提供的声明式服务调用组件让我们省去了编写防腐层中 Client 的代码实现;
Spring Cloud Config 提供了分布式的配置中心等。
这些组件在上面所示的架构中,作为微服务架构的基础设施而存在。当我们使用这样的微服务框架时,就可以让设计与开发人员只需要专注于微服务内部的设计,即领域逻辑的实现,实际上就是对软件复杂度的应对。通过限界上下文(微服务)实现对业务的分而治之,从而降低业务复杂度;而微服务架构自身虽然会带来技术复杂度的增加,但技术复杂度已经转移到了微服务框架来完成,从而整体降低了应用开发人员的开发难度。
倘若采用单体架构,我们也需保证其向微服务演化的可能。因此,这两种风格的选择对于限界上下文内部的代码模型并无影响。但我们还需要为整个系统建立一个一致的系统架构。为了保证关注点分离,整个系统的架构同样需要进行分层,并遵循整洁架构的思想。
在对整个系统架构进行分层架构设计时,需要考虑用户展现层、应用层和基础设施层与限界上下文内各层次之间的关系。我认为,限界上下文的范围包含了除用户展现层在外的其他各层。其中,应用层包含了应用服务,由它来完成领域对象的调用以及对其他横切关注点的调用。基础设施层的北向网关提供了对外公开的开放主机服务,通常被定义为 RESTful 服务。那么,对于整个系统架构而言,还需要定义系统层次的应用服务与 RESTful 服务么?如下图所示:
如果我们参考微服务架构风格,就会发现上图的控制器层暴露了所有的服务接口,相当于 API Gateway 的功能,上图的应用层用于管理各个限界上下文应用服务的协作,相当于服务注册与发现组件的功能。微服务框架提供的 API Gateway 和服务注册与发现组件仅仅是一个外观Facade内部并没有包含任何应用逻辑和领域逻辑。而在单体架构中由于所有的限界上下文都部署在一个服务中因而并不需要服务的注册与发现功能每个限界上下文都有控制器定义了对外公开的 RESTful 服务,且所有的这些 RESTful 服务都绑定到唯一的入口(如 Spring Boot 要求定义的 Application 类)上,区别仅仅是代码模型的隔离罢了,自然也就不需要 API Gateway。故而在系统架构层次保留二者并没有任何意义。
当然,也有例外。譬如说我们需要为整个单体架构提供一些与业务无关的 RESTful 服务如健康检查、监控等。另外倘若需要组合多个限界上下文的领域模型似乎也有了保留应用层的必要。Vernon 在《实现领域驱动设计》一书中就提到了这种“组合多个限界上下文”的场景,如下图所示:
Vernon 认为:
……应用层不是成了一个拥有内建防腐层的新领域模型吗?是的,它是一个新的、廉价的限界上下文。在该上下文中,应用服务对多个 DTO 进行合并,产生的结果有点像贫血领域模型。
实际上Vernon 在这里谈到的组合功能,目的是为了组装一个满足客户端调用的服务对象。但我认为定义这样专属的应用服务并非必须。归根结底,这个应用服务要做的事情就是对多个限界上下文领域模型的协调与组装。这种需求必然要结合具体的业务场景,例如订单对象需要组合来自不同限界上下文的商品信息、客户信息、店铺信息等。该业务场景虽然牵涉到多个限界上下文领域模型的协调,但必然存在一个主领域对应的限界上下文。这个限界上下文提供的应用服务才是该业务场景需要实现的业务价值,因此就应该将这个应用服务定义在当前限界上下文,而非整个系统架构的应用层,又或者为其建立一个新的廉价的限界上下文。而在该限界上下文内部,应用层或领域层可以通过防腐层与其他限界上下文协作,共同为这个业务提供支持。除非,这个业务场景要完成的业务目标不属于之前识别的任何一个限界上下文。
再来考虑用户展现层的场景。假设需要支持多种前端应用,且不同前端应用需要不同的视图模型和交互逻辑。考虑到前端资源有限,同时保证前端代码的业务无关性,我们可以在系统架构层面上,定义一个统一的接口层。这个接口层位于服务端,提供了与前端界面对应的服务端组件,并成为组成用户界面的一部分。在这个接口层中,我们可以为不同的前端提供不同的服务端组件。由于引入的这一接口层具有后端服务的特征,却又为前端提供服务,因而被称之为 BFFBackends For Frontends为前端提供的后端
引入的 BFF 往往是为了协调前端开发人员与后端开发人员的沟通协作问题。前端开发人员理解用户界面的设计,后端开发人员却只为垂直领域(即特性)设计服务接口,就使得二者并不能很好地实现模型之间的匹配。既然 BFF 是为前端 UI 提供的,最佳的做法就是让前端开发人员自己来定义。这也是在项目实践中 Node.js 扮演的重要作用,如下图所示:
图中为浏览器 UI 调用提供的 UI Layer即 BFF它实则是在服务器与浏览器之间增加了一个 Node.js 中间层。各层的职责如下表所示:
Java
Node.js
JS + HTML + CSS
服务层
跑在服务器上的 JS
跑在浏览器上的 JS
提供数据接口
转发数据,串接服务
CSS、JS 加载与运行
维持数据稳定
路由设计,控制逻辑
DOM 操作
封装业务逻辑
渲染页面,体验优化
共用模板、路由
显然BFF 的引入虽然是架构决策的一部分但严格意义上讲它并不属于后端架构。因而BFF 的设计并不在领域驱动战略设计的考虑范围之内。
最后再来考虑基础设施层。除了限界上下文自身需要的基础设施之外,在系统架构层面仍然可能需要为这些限界上下文提供公共的基础设施组件,例如对 Excel 或 CSV 文件的导入导出消息的发布与订阅、Telnet 通信等。这些组件往往是通用的,许多限界上下文都会使用它们,因而应该放在系统的基础设施层而被限界上下文重用,又或者定义为完全独立的与第三方框架同等级别的公共组件。理想状态下,这些公共组件的调用应由属于限界上下文自己的基础设施实现调用。倘若它们被限界上下文的领域对象或应用服务直接调用(即绕开自身的基础设施层),则应该遵循整洁架构思想,在系统架构层引入 interfaces 包,为这些具体实现定义抽象接口。
在运用领域驱动设计时还需要提供遵照领域驱动设计尤其是战术设计要素提供的基本构造块Building Block例如对 Identity 的实现、值对象、实体以及领域事件的抽象、聚合根的构造型等。你可以理解为这些构造块组成了支持领域驱动设计的框架。如果没有单独剥离出这个框架,这些构造块也将作为系统代码模型的一部分。
综上所述,我们选择的架构风格会影响到系统的代码模型。假设我们要设计的系统为 ecommerce选择单体架构风格则系统架构与限界上下文的代码模型如下所示
practiceddd
- ecommerce
- core
- Identity
- ValueObject
- Entity
- DomainEvent
- AggregateRoot
- controllers
- HealthController
- MonitorController
- application视具体情况而定
- interfaces
- io
- telnet
- message
- gateways
- io
- telnet
- message
- ordercontext
- application
- interfaces
- domain
- repositories
- gateways
- productcontext
- application
- interfaces
- domain
如果选择微服务架构风格,通常不需要建立一个大一统的代码模型,而是按照内聚的职责将这些职责分别封装到各自的限界上下文中,又或者定义为公共组件以二进制依赖的方式被微服务调用。这些公共组件应该各自构建为单独的包,保证重用和变化的粒度。如果选择 CQRS 架构风格,就可以在限界上下文的代码模型中为 command 和 query 分别建立 module领域驱动设计中的设计要素使得它们的代码模型可以独自演化毕竟命令和查询的领域模型是完全不同的。基于质量因素的考虑我们甚至可以为同一个领域的 command 和 query 各自建立专有的限界上下文。在 command 上下文中,除了增加了 command 类和 event 类以及对应的 handler 之外,遵循前面讲述的限界上下文代码模型,而 query 上下文的领域模型就可以简化,例如直接运用事务脚本或表模块模式。

View File

@ -0,0 +1,274 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
030 实践 先启阶段的需求分析
从本课开始,我将通过一个完整的真实案例 EAS 系统来展示领域驱动的战略设计过程。通过 EAS 项目,我会把前面讲解的各个知识点贯穿起来,作为实践领域驱动设计的参考设计过程呈现出来。在这个战略设计过程中,曾经因为未曾识别出项目的业务愿景而让需求分析走了一段较长的弯路;因为没有就领域概念形成统一语言,而导致领域建模出现偏差;限界上下文的识别也经历了反复迭代与修改,并经历了领域驱动架构的演进,直至获得相对稳定的领域模型与代码模型。限于篇幅,我无法呈现整个设计过程的完整全貌,但也尽可能将设计过程中遭遇的典型问题、做出的设计决策进行了阐述,并给出了部分设计结果作为参考。
通过访问 GitHub 上的 eas-ddd 项目 获得该项目的介绍与源代码,访问 eas-ddd 项目的 Wiki 可以获得 EAS 项目的需求与项目概况,限界上下文划分;访问问题列表可以获得该项目的任务列表。
背景:企业应用套件
企业应用套件Enterprise Application SuiteEAS是一个根据软件集团公司应用信息化的要求而开发的企业级应用软件。EAS 系统提供了大量简单、快捷的操作接口,使得集团相关部门能够更快捷、更方便、更高效地处理日常事务工作,并为管理者提供决策参考、流程简化,建立集团与各部门、员工之间交流的通道,有效地提高工作效率,实现整个集团的信息化管理。
EAS 系统为企业搭建了一个数据共享与业务协同平台,实现了人力资源、客户资源与项目资源的整合,系统包括人力资源管理、客户关系管理和项目过程管理等主要模块。系统用户为集团的所有员工,但角色的不同,决定了他们关注点之间的区别。
实施先启阶段
先启阶段是软件开发生命周期的准备阶段,力求通过较短的周期让开发团队与客户就系统范围、愿景与目标、主要需求、风险与问题、技术架构和发布迭代计划达成共识。在领域驱动设计过程中,可以将先启阶段当做是需求捕获、场景分析与建立统一语言的一种敏捷实践。
确定利益相关人
在制定先启计划时我们需要先确定利益相关人EAS 涉及到的组织部门包括人力资源部、市场部、项目管理部、服务中心、财务中心以及各子公司。因此,除了开发团队之外,利益相关人就包括整个集团的决策层,相关部门的负责人与具体操作 EAS 系统的集团员工。在先启阶段执行相关活动时,我们会根据这些活动的情况邀请对应的利益相关人。
制订先启计划
先启阶段是一个重要的项目开发环节,也可以视为一个特殊的迭代。尤其是先启阶段需要协调利益相关人和开发团队之间的交流与协作,就需要积极地将利益相关人引入到整个先启阶段,参与具体的先启活动。为此,我们需要事先制订一个明确的先启计划,并与利益相关人确定活动(会议)时间,保证这些提供重要输入的利益相关人都能准时参加。
EAS 先启计划(仅列出与需求分析有关的活动)如下所示:
活动
活动项目
集团决策层
子公司负责人
人力资源部
市场部
项目管理部
服务中心
财务中心
项目经理
需求分析师
技术负责人
启动会议
项目介绍
X
X
X
X
X
X
X
X
X
X
启动会议
确定业务期望和愿景
X
X
X
X
X
X
X
X
启动会议
优先级权衡
X
X
X
X
X
X
需求
对问题域的共同理解
X
X
X
X
X
X
X
X
X
需求
确定项目的业务范围
X
X
X
X
X
X
X
X
需求
确定业务流程
X
X
X
X
X
X
X
X
X
需求
确定史诗级故事与主故事
X
X
X
确定业务期望和愿景
在确定业务愿景时,我们一开始重点调研了人力资源部、市场部与项目管理部的相关人员,他们都是识别出来的利益相关人。每个部门的员工都向我们提出了切合他们实际需要的业务功能,这些功能包括:
市场部对客户和需求的管理,对合同的跟踪;
项目管理部对项目和项目人员的管理,对项目进度的跟踪;
人力资源部负责招聘人才,管理员工的日常工作包括工作日志、考勤等。
然而,随着需求的越来越多,我们反而越来越迷茫,仿佛迷失在一张巨细无靡的需求大网中。这张网没有放过任何可能溜走的需求,可需求的详尽非但没有呈现出清晰的业务目标,反而越发的不明朗。看起来,我们撒出了一张威能强大的网,可惜选错了捕捉鱼虾的水域。
我们需要确定系统的业务期望与愿景,而不是从一开始就沉入到如蛛丝一般细而密的需求细节中。重要的利益相关人是集团管理层,他们只关注整体需求与系统目标,至于各个细节的功能不过是为了完成这一目标才提供的功能支持罢了。这正是先启阶段需要开展的需求活动,正如在[第 1-6 课]中所说:“需要确定项目的利益相关人,并通过和这些利益相关人的沟通,确定系统的业务期望与愿景。在期望与愿景的核心目标指导下,团队与客户才可能就问题域达成共同理解。”
通过与集团决策层领导的沟通交流,我们最终确定了整个系统的业务期望与愿景:“避免信息孤岛,实现人力资源的可控,从而达到人力资源的供需平衡。” 例如,客户需要集团提供 20 名各个层次的 Java 开发人员,则市场部门在确定是否签订该合同之前,需要通过 EAS 查询集团的人力资源库,了解现有的人力资源是否匹配客户需求。如果匹配,还需要各个参与部门审核人力成本,决定合同标的;如果集团当前的人力资源无法满足客户需求,就需要人力资源部提早启动招聘流程,或从人才储备库中寻找到满足需求的候选人。通过 EAS管理人员还能够及时了解开发人员的闲置率跟踪项目的进展情况明确开发人员在项目中承担的职责和任务完成质量。
当我们与客户就这个业务期望与愿景达成共识后,它就成为了整个需求分析与建模阶段最重要的指导原则。当我们需要判断某个需求是否有业务价值时,可以参考这一指导原则;当我们无法识别不同用户故事的优先级时,可以借鉴这一指导原则;当我们需要确定核心领域与子领域时,我们可以遵循这一指导原则,这一指导原则可以视为是团队与客户就问题域达成的共同理解。
优先级权衡
在面对客户源源不断提出的需求时,最难做出决策的是确定这些需求的优先级。如果是业务需求,我们应该基于系统的业务期望与愿景,判断这些业务需求与实现该愿景的关联程度,并以此来作为优先级的衡量标准。如果是质量属性又或者是管理上的要求,就需要客户和我们一起给出高屋建瓴般的权衡标准。
一种比较好的实践是采用所谓的“价值滑条Value Slider即基于该需求协商谈判的可能性高低列出所有的质量属性需求与管理要求由客户来做出判断。若协商谈判的可能性高则说明该需求是可以协商的可以做出让步的则滑条向左意味着优先级低若协商谈判的可能性低就说明不可商量没有妥协的余地滑条向右意味着优先级高。在由客户确定每条需求处于这个“价值滑条”的位置时有一个约束是任何两个或两个以上的需求对应的滑条都不能出现在同一列上如下图所示
如果需求对应的滑条可能出现在同一列,就需要客户做出权衡和决策,强迫他们移动滑条的位置,这就意味着调整了它们的优先级。上图作为 EAS 的“价值滑条”,意味着我们必须在规定的“最终期限”交付可用的产品,但是我们可以根据对功能的排期,优先实现高优先级的主要功能,同时也可以在人力不足或周期紧张的情况下,增加人手,并适度降低产品质量。
对问题域的共同理解
在先启阶段对问题域Problem Solution的识别其实就是对客户痛点的识别。之所以要开发这个软件目的就是解决这些痛点为应对这些问题提供具有业务价值的功能。在识别痛点的过程中需要始终从业务期望与愿景出发与不同的利益相关人进行交流如此才能达成对问题域的共同理解。
对于集团决策层,要解决“供需平衡”这个痛点,就需要及时了解我们面临哪些客户需求,目前有哪些人力资源可用,这就需要打破市场部、人力资源部与项目管理部之间的信息壁垒,对市场需求、人力资源、项目的信息进行统计,提供直观的分析结果,进而根据这些分析结果为管理决策提供支持。
市场部员工面临的痛点是如何与客户建立良好的合作关系,快速地响应客户需求,敏锐地发现潜在客户,掌握客户动态,进而针对潜在客户开展针对性的市场活动。市场部员工希望能够建立快速通道,及时明确项目承担部门(即子公司)是否能够满足客户需求,降低市场成本。市场部门还需要准确把握需求的进展情况,跟踪合同签署流程,提高客户满意度。
人力资源部员工(招聘专员)的痛点是如何制订合理的招聘计划,使得招聘的人才满足日益增长的客户需求,又不至于产生大量的人力资源闲置,导致集团的人力成本浪费。站在精细领域的角度考虑,从潜在的市场需求开始,招聘专员就需要与市场部、子公司共同确定招聘计划,制定计划的依据在于潜在的人力资源需求,包括对技能水平的要求、语言能力的要求,同时也需要考虑目前子公司的员工利用率,并参考历史的供需关系来做出尽可能准确的预测。
由于集团的项目类型复杂,特别牵涉到外派项目,项目成员不在公司集团内部,对人员的管理成为项目管理部的核心问题。此外,跟踪和了解项目进度不仅仅是项目管理人员的诉求,市场部同样关心,因为这牵涉到他们与客户的合作关系,并影响到客户满意度。
针对前面对客户痛点的分析,围绕“供需平衡”这一业务期望与愿景,我们可以将 EAS 划分为如下核心子领域:
决策分析
市场需求管理
客户关系管理
员工管理
人才招聘
项目进度管理
除了这些核心子领域外,诸如组织结构、认证与授权都属于通用的子领域,每个核心子领域都需要调用这些子领域提供的功能。注意,通用子领域提供的功能虽然不是系统业务的核心,但缺少这些功能,业务却无法流转。之所以没有将其识别为核心子领域,实则是通过对问题域的理解分析得来。例如,组织结构管理是保证业务流程运转以及员工管理的关键,用户的认证与授权则是为了保证系统的访问安全,但它并没有直接对“供需平衡”这一业务愿景提供业务价值,在前面的痛点分析中,它们也不是利益相关人亟待解决的痛点。
在分辨系统的利益相关人时,服务中心作为参与 EAS 的业务部门,主要是为项目及项目人员提供工位和硬件资源。它要解决的是资源分配的问题,虽然在某种程度上可以降低运营成本,但却与我们确定的业务愿景没有直接的关系。因此我们将该子域作为一种支撑子领域。
通过先启阶段对客户痛点的分析,我们形成了对 EAS 问题域的共同理解:
问题域对统一语言的影响
当我们在分辨市场需求管理这个问题域时我们认为有几个领域概念是模糊不清的即合同Contract、市场需求Market Requirement、客户需求Client Requirement它们三者之间的关系是什么究竟有什么样的区别如果不为它们建立一个达成一致共识的统一语言就有可能影响该问题域的领域模型。
通过与市场部员工的交流,我们发现市场部对这些概念也是模糊不清的,甚至在很多场景中交替使用了这些概念,而没有一个清晰的定义。在与市场部人员的交谈过程中,他们有时候还提到了“市场需求订单”这个概念。例如,在描写市场需求时,他们会提到“录入市场需求”,但同时又会提到“跟踪市场需求订单”和“查询市场需求订单”。在讨论“客户需求”时,他们提到需要为其指定“承担者”,而在讨论“市场需求”时,却从未提及这一功能。这似乎是“客户需求”与“市场需求”之间的区别。对于“合同”的理解,他们一致认为这是一个法律概念,等同于集团或子公司作为乙方和作为甲方的客户签订的合作协议,并以合同要件的形式存在。
鉴于这些概念存在诸多歧义我们和市场部人员一起梳理统一语言一致认为需要引入“订单Order”的概念。订单不是需求无论是客户需求还是市场需求而是借鉴了电商系统中订单的概念把客户提出的项目合作视为订单的商品。“客户提出的项目合作”其实就是“客户需求”相当于是“订单”中的订单项。一个订单可以包含多个“客户需求”例如同一个客户可能提出三条需求
需求1需要 5 名高级 Java 程序员、10 名中级程序员
需求2需要 8 名初级 .NET 程序员
需求3需要开发一个 OA 系统
虽然是同一个客户且向市场部同时提出了这些需求但毫无疑问这应该是三个不同的需求。但从“订单”的角度来说这些客户需求都属于同一个订单。这与一个销售订单可以包含多个不同订单项是相似的。当然一个订单到底包含哪几个客户需求取决于市场部与客户洽谈合作的业务背景。例如我们也可以将前面提到的需求1和需求2放入到一个订单中而把需求3单独放到另一个订单。
在引入“订单”概念后,市场需求与客户需求的区别也就一目了然了。市场需求是市场部售前人员了解到的需求,并未经过评估,公司也不知道能否满足需求,以及该需求是否值得去做。这也是为何市场需求无需指定“需求承担者”的原因。市场需求在经过各子公司的评估以及财务人员的审核后,就可以细化该市场需求,并经过与客户充分沟通后,最后形成订单。这个订单形成了一个初步合作意向,评估通过的每一条市场需求,则转换为订单中的客户需求。
我们仍然保留了“合同”的概念。“合同”领域概念与现实世界的“合同”法律概念相对应它与订单存在相关性但本质上并不相同。例如一个订单中的每个客户需求可以由不同的子公司来承担Owner但合同却需要明确甲方和乙方。订单并没有合同需要的那些法律条款。未签订的合同内容确实有很大部分来自订单的内容但也只是其中商务合作内容的一部分而已。在确定了订单后市场部人员可以跟踪订单的状态并且在订单状态发生变更时修改对应的合同状态。显然合同的状态与订单的状态并不一致。
在我们引入“订单”这个概念后,市场需求管理这个问题域就发生了细微的变化。我们可以将这个问题域更名为订单,也可以将订单领域概念视为解决方案域的组成部分,继续保留市场需求管理这个问题域,而将订单视为限界上下文。
在先启阶段,我们不一定需要领域建模。不过,当我们在识别问题域时发现领域概念无法形成统一语言时,确实可以就领域概念的定义展开讨论与分析。若发现仍有不清晰的地方,就可以通过可视化的领域模型来打消开发团队与领域专家包括客户在概念认知上的不一致。例如,我们针对市场需求问题域建立了如下的领域模型:
这个领域模型非常清晰地表达了订单Order与客户需求Client Requirement的一对多关系且客户需求是放在了订单的聚合边界内。合同Contract是一个单独的领域概念但它与订单存在一个弱关联关系。市场需求Market Requirment在通过评估Assessment后它会成为订单的一个输入转换为客户需求。在这个领域模型中我们可以直观地看到客户需求需要指定承担者Owner同时订单还会和客户关系管理问题域中的客户Client产生关联。显然这样清晰表达的领域模型有助于我们和领域专家客户的沟通进而针对这些领域概念达成共识形成统一语言。
确定项目的业务范围
之所以要确定项目的业务范围,是为了明确整个系统的边界。明确系统边界是架构设计的重要前提,它一方面可以明确职责划分,了解哪些内容才属于领域驱动设计的范畴;另一方面则可以事先明确当前系统需要与哪些外部系统集成。
EAS 是软件集团公司的信息化平台但这个信息化平台是为了解决项目开发的“供需平衡”因此它围绕着市场需求、人力资源和项目开发为需求主线其他的信息化产品如办公自动化OA系统、财务系统、工资结算系统等都会作为外部系统与 EAS 的功能集成。明确了这样的业务范围有助于我们甄别需求的边界,并做到功能的收敛。在识别系统的史诗级故事与主故事时,也应该确保这个业务范围边界,同时这个业务范围还会影响到发布与迭代计划的制订。
确定项目的业务范围还有助于架构层面的考量,通常,我建议引入 C4 模型的系统上下文System Context来体现系统的边界。EAS 的系统上下文如下所示:
确定了系统上下文后,可以为后续的上下文映射提供重要参考,如上图所示的 OA 系统、财务系统与工资结算系统可以被视为第三方服务而与 EAS 的限界上下文产生协作。依赖的方向决定了我们选择上下文协作的模式。而系统上下文中的考勤机则会作为 EAS 访问的外部资源,需得做好系统与该机器设备的抽象与隔离。
确定业务流程
在明确了系统的业务愿景,并就问题域达成共同理解后,我们还需要让主要的业务功能“动”起来。这就需要确定业务流程,因为它可以更好地体现完整全面的领域场景,突出参与者(部门)与用例之间的协作。
在先启阶段,没有必要将整个系统的所有业务流程都绘制出来,重点是抓住体现业务愿景这个核心价值的主流程。既然 EAS 以“人力资源的供需平衡”为关注核心,因此所有参与方需要执行的主要功能都与该核心价值有关。通过梳理需求,开发团队在与客户充分交流后,抽象出需求方、供应方这两个核心参与者,从而绘制出供需双方的协作示意图:
这个协作示意图非常清晰地体现了需求与供应之间的关系展现了这个核心流程的关键环节。注意这个协作示意图并非项目开始之前的当前状态As-Is而是期望解决供需平衡问题的将来状态To-Be。这种协作关系正好体现了打破部门之间信息壁垒的愿望。由此我们就可以绘制出整个系统的核心流程
作为核心流程的子流程,项目管理流程与招聘流程是更低一级的业务流程。在先启阶段,如果为了获得更加准确的主故事列表,仍然有必要进一步细化这些子流程。从敏捷开发的角度讲,我们也可以将这些流程的细化放到对应迭代的需求分析活动中,以便于尽快完成先启阶段,进入到项目的正式迭代阶段。毕竟在确定了产品的待办项(史诗级故事与主故事)之后,已经足以帮助团队确定发布与迭代计划了。

View File

@ -0,0 +1,318 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
031 实践 先启阶段的领域场景分析(上)
在先启阶段,我们确定了 EAS 的问题域与核心的业务流程然后根据业务期望与愿景确定项目的业务范围明确史诗级故事和主故事。这个过程既有利于我们对项目的整体理解以便于确定需求列表、排定需求的优先级从而制订发布与迭代计划又利于对领域进行建模确定限界上下文和上下文映射进而设计整个系统的架构。同时我们又要准确地把握“故事Story”的粒度不至于沉入到过分细粒度的需求实现细节影响了先启阶段的实施进度。
所谓故事的层次(粒度)并没有固定的标准,在用户故事地图中,提出了三个层次:
用户活动
用户任务
用户故事
这里提到的用户活动就相当于史诗级故事,组成用户活动的用户任务相当于主故事,再按照 INVEST 原则继续对其进行分解,就可以获得在敏捷迭代中构成开发任务的用户故事。在[第 2-4 课:运用领域场景分析提炼领域知识(下)]中,我给出了三种不同层次的领域场景分析方法。
用例尤其是用例图的抽象能力更强,更擅长于对系统整体需求进行场景分析;
用户故事提供了场景分析的固定模式,善于表达具体场景的业务细节;
测试驱动开发则强调对业务的分解,利用编写测试用例的形式驱动领域建模,即使不采用测试先行,让开发者转换为调用者角度去思考领域对象及行为,也是一种很好的建模思想与方法。
因此,我个人比较倾向于在先启阶段的需求分析过程中,使用用例来表述领域场景。恰好在 Cockburn 的著作《有效编写用例》中,他提到用例的层次包括:概要目标、用户目标和子功能。例如:
这里的用户目标就代表着具有业务价值的领域场景,也就是我们需要识别出来的主用例,它由多个子功能组成,它们之间的关系就是主故事与用户故事之间的差别。结合前面分析的问题域和业务流程,我们可以初步获得 EAS 的史诗级故事与主故事。
史诗级故事和主故事
结合 EAS 的业务愿景和核心流程,我们通过深入地需求调研,获得了 EAS 的史诗级故事和主故事。
管理客户关系
通过对客户全方位信息的统一管理,可以实现市场部工作人员、需求承担部门之间快捷、方便的信息共享,提高工作效率,并且可为集团负责人提供最实时、有效的客户信息,包括潜在客户的信息。这些信息包括客户基本信息、市场部拜访客户的记录还有客户活动记录、客户的背景资料以及客户公司的商务负责人资料。
管理客户关系的主要目的是改进和维护客户关系,因此它包括如下主故事:
管理潜在客户信息
管理客户信息
对客户进行分类
查询客户信息
客户满意度调查
维护拜访记录
维护客户活动记录
管理市场需求
市场人员可以收集市场需求,并与客户接触,明确这些市场需求。在创建市场需求后,需要对市场需求进行评估,进行财务核算,从而确定需求订单和客户需求,确定需求承担者。市场人员应随时跟踪需求订单的状态,并及时向客户反馈。同时,市场人员还可以查询市场需求和需求订单。包括的主故事有:
录入市场需求
查询市场需求
修改市场需求
评估市场需求
创建需求订单
指定客户需求的承担者
跟踪需求订单状态
更新需求订单状态
管理商务合同
签订的合同来自于需求订单,一个需求订单可能会创建多份商务合同。在正式签订合同之前,作为合同的创建者,可以向相关部门的负责人发起合同评审流程,并按照评审后的要求修改合同。为了便于对合同的管理,应提供查询合同与跟踪合同进展状态的功能。合同一旦经过甲方、乙方审批通过并正式签订后,就不允许任何角色和用户修改。如果要修改合同,应该是增加一份附加合同。包括如下主故事:
录入合同
添加附加合同
指定合同承担者
更新合同状态
为合同添加评论
管理员工
人力资源部负责对员工基本信息的管理,包括员工技能列表、语言技能、项目经验等,还要管理每位员工的日常考勤。根据组织结构的定义与授权,每位员工的直接管理者还要检查员工工作日志的填写情况,了解每位员工的工作状况。包括如下主故事:
管理员工基本信息
将储备人才转为正式员工
管理员工的劳务合同
追加员工的项目经历
考勤
填写工作日志
招聘人才
根据市场部的需求以及集团自我的发展,作为人力资源部的管理人员,需要制定招聘计划,更新和查询招聘状态。每次应聘人员的面试活动(包括电话面试、笔试、技术面试等)以及测评结果都需要记录下来归档。对于每一位应聘者,需要对投递过来的简历进行归档和录入。人力资源部的工作人员可以根据自己需要对储备人才进行分类,从而对简历进行分类管理。包括如下主故事:
制定招聘计划
审核招聘计划
修改招聘计划
查看招聘计划
删除招聘计划
输入面试记录与测评结果
管理储备人才信息
管理储备人才简历
管理项目
集团的项目有两种类型:承包项目和外派项目。不同项目类型的流程是不相同的。承包项目牵涉到对整个项目进度的跟踪,从需求到设计到开发实现和测试的全生命周期管理;外派项目则是人力资源外包,仅仅需要管理外派人员的工作情况即可。项目管理人员需要创建项目,根据项目类型和管理流程选择计划模板制订项目计划,并通过该模块查询和跟踪项目进展情况,更新项目状态。包括的主故事为:
创建项目
制订项目计划
创建迭代任务
分配任务给项目成员
更新项目状态
更新任务状态
为任务添加评论
查询项目情况
跟踪项目进度
查看指定迭代的所有任务
管理项目成员
一旦立项后,就可以为项目分配项目成员以及分配项目成员的角色,项目管理人员可以对项目成员的信息进行管理。包括如下主故事:
添加项目成员
移除项目成员
调整项目成员的角色
查看项目成员的任务状态
管理工位与硬件资源
作为服务中心的工作人员,可以管理公司现有的硬件资源信息以及工位信息。通过系统,可以将硬件资源与工位分配给集团的员工,若未分配,则为闲置硬件与工位。包括如下主故事:
管理硬件资源
管理工位
决策分析
作为集团的管理者,需要查看集团各部门的工作情况,包括需求订单情况、项目进展情况、人员利用率等综合报表,并结合各部门具体情况,定期提供日报表、周报表、月报表、季度报表和年报表。
运用用例分析方法
在先启阶段的领域场景分析过程中,我们可以运用用例分析方法对 EAS 进行需求分析。用例的驱动力是业务流程与参与者,参考的内容则为业已识别出来的史诗级故事和主故事。同时,在识别用例的过程中,还应该尽量通过用例表达领域知识,力求获得“统一语言”。典型的用例描述是一个动宾短语,体现了参与者在业务场景需要履行的职责,又或者是满足用例规格的业务行为。
为了保证用例分析方法的简洁,避免在先启阶段出现“分析瘫痪”,我将传统的用例分析方法分为两个步骤。在先启阶段进行领域场景分析时,只需要使用用例图,而非详尽的用例规格说明。至于用例的流程描述则过于死板和繁琐,我建议使用用户故事对需求进行阐述,并作为第二个步骤放在迭代开始后的领域驱动战术设计阶段。
在绘制用例图时可以基于识别出来的史诗级故事来绘制亦可以按照参与业务流程的参与者Actor来绘制。无论采用何种方法这个过程都需要团队与领域专家通力合作从业务而非技术实现的角度剖析领域需求最后推导出真正能表达领域概念的用例图。
在这里我展现的用例分析方法以参与者Actor为用例分析的起点分析步骤为
确定业务流程通过业务流程识别参与者Actor
根据每个参与者识别属于该参与者的用例,遵循一个参与者一张用例图的原则,保证用例图的直观与清晰;
对识别出来的用例根据语义相关性和功能相关性进行分类,确定用例的主题边界,并对每个主题进行命名。
根据业务流程确定参与者
如果考虑 EAS 的核心业务流程,可以初步识别出如下参与者:
集团决策者
市场人员
子公司
财务
人事专员
招聘专员
人力资源总监
面试官
项目管理办公室
项目经理
项目成员
员工
部门经理
服务中心
在识别参与者Actor要注意以下问题。
参与者不一定是人,可以是一个系统、服务或模块,也可以是一个部门。例如,定时器可以根据事先设定的规则给相关人员发送通知,此时,定时器作为一个组件成为了参与者;项目管理办公室发起项目的立项,此时,项目管理办公室作为一个部门成为了参与者。
当参与者为同一部门的不同角色时,可以考虑参与者的泛化关系,也可以理解为完全不同的参与者。例如,招聘专员参与的用例包括“制定招聘计划”、“修改招聘计划”和“审核招聘计划”,但第三个用例只有人力资源总监才具有操作权限。这时,可以认为人力资源总监是招聘专员的一种特化,但亦可以视为两个完全不同的参与者。当人力资源总监在操作前两个用例时,本质是扮演了招聘专员这个参与者在执行。
参与者不同于设计模型中的角色Role前者来自领域场景是真实业务场景的参与对象后者是对职责的抽象。例如“评论商品”用例的参与者可以是买家和卖家但在设计模型中可以抽象为评论者角色。
根据参与者识别用例
在识别参与者时一些用户体验设计的实践是为参与者建立一个用户画像Persona即给出更为具体的用户特征和属性从而得到一个如身临其境一般的场景参与者然后设身处地思考他或者她是如何参与到这个领域场景中的。无论是否建立用户画像这种场景模拟的方式对于用例分析都是有帮助的。
市场人员的用例图
让我们首先思考“市场人员”这个参与者。作为一家软件外包的集团公司,它与产品销售公司不同,没有售前和售后人员来负责推销商品和开展售后维护,保持与客户之间的良好关系。市场部作为开拓市场、寻找客户合作机会、维持客户关系、开展需求合作谈判的职能部门,承担了需求管理、客户管理和合同管理的职责,而市场人员作为市场部员工,全程参与了从市场需求、客户洽谈到合同签署的整个市场活动全过程。因此,市场人员参与的用例几乎涵盖了客户关系管理与市场需求两个核心子领域。这些用例关系如下图所示:
在绘制这个用例图时,我主要参考了以下内容。
识别的主故事:分析这些主故事的用户目标是什么,进而就可以确定应该是哪个参与者发起该用例。
对市场人员的调研:与市场人员进行沟通,了解该角色目前的工作任务。
业务流程:从组成业务流程的各个环节判断参与者与功能之间的关系。
子公司的用例图
子公司在 EAS 中,主要作为需求的承担者。承担需求的工作属于项目管理的范畴,真正的参与者是项目经理和项目成员。在核心业务流程中,当市场人员在创建了市场需求后,要由该需求的承担者即子公司负责评估,签订合同时,也需要子公司确认。此时,子公司会以部门作为整体参与到领域场景中,用例图为:
在识别“确认合同履行”用例时,我们仔细分析了业务需求。由于子公司是合同的承担者,因此履行需求合约的乙方是子公司而非集团市场部。市场人员会作为需求的委托者草拟合同,并在 EAS 中负责创建合同及上传合同附件。合同的真正签署者是子公司,但这个签订的过程是线下行为,子公司领导只需要在 EAS 系统中完成合同的确认即可。
在子公司的用例图中,包含了“为合同添加评论”用例,它同时也是市场人员的用例。不同参与者使用相同的用例,这是完全正常的。但它也给我们传递了一个信号,就是在设计模型中,我们可以考虑为该用例抽象一个角色,如“合同评论者”。在编码实现时,该角色可能会作为一个权限角色,用以控制评论合同的功能权限,也可以考虑为其定义一个角色接口。
财务的用例图
财务中心的“财务”参与者也参与了市场需求的核心业务流程:
根据前面对系统上下文的定义EAS 的业务范围并未包含工资结算、财务成本核算等。因此在用例图中,财务仅仅负责市场需求的财务核算。
人事专员的用例图
我将人力资源部中负责管理员工信息的参与者定义为“人事专员”,员工的基本信息管理与考勤都由他(她)来负责。其用例图为:
对于员工的管理,我最初定义的用例为“管理员工信息”;然而,“管理”这个词语稍显宽泛,无法准确表达领域行为。这种过于抽象的用例描述可能会导致我们忽略一些必要的领域概念,并让领域行为变得模糊化。经过与人事专员的沟通,我们一致认为在员工管理的场景中,对员工的管理其实包括以下内容。
办理员工入职:入职体现了领域概念,要好于新建员工的描述。
办理员工离职:离职体现了领域概念,要好于删除员工的描述,何况员工的离职未必一定要删除该员工记录。
录入员工信息:录入员工基本信息、项目经历、技能、语言能力等。
在主故事列表中,属于人事专员的职责还包括“追加项目经历”。然而,通过深入分析用例,我们发现该用例其实应该发生项目管理过程中,作为“添加项目成员”的扩展用例,一旦员工被加入到项目,就会被触发。
招聘专员的用例图
负责招聘的人力资源部员工被定义为“招聘专员”,用例图如下所示:
招聘专员在制定或修改招聘计划之后,需要由人力资源总监对计划进行审核,这是两个不同的参与者,人力资源总监的用例图为:
除了招聘专员会参与面试过程之外,人力资源部之外的其他员工可能会作为面试官参与面试。无论是招聘专员,还是面试官,都需要在面试之后输入面试记录与面试结果,故而引入了“面试官”参与者:
员工的用例图
集团的每一名员工都需要考勤和填报工作日志。注意在下图,我没有像人事专员与人力资源总监那样,将员工和部门经理定义为两个完全独立的参与者,而是采用泛化关系表达。仔细体味这之间的微妙差别。人力资源总监可以审核招聘计划,但却不会直接去制定或修改招聘计划,他(她)与人事专员之间有一个比较明显的层级关系,对应的是不同的权限。而部门经理就是一名员工,这种泛化关系是确定无疑的。
注意用例图中“查询打卡记录”和“查询出勤记录”之间的差异。打卡记录是考勤机每天留存的信息,出勤记录则是根据集团的考勤制度并结合员工的请假信息和打卡记录生成的记录内容。
工作日志定时器的用例图
这里还有一个特殊的参与者,在之前识别参与者时被忽略了,那就是提醒填报工作日志的定时器:
项目管理办公室的用例图
项目管理办公室是以部门作为参与者在项目管理场景中出现,它是整个项目的发起者、评审者,也只有它才有权终止项目或结束项目。项目管理办公室参与了整个项目管理流程的监督,但并不参与项目的具体活动:
“立项”与“结项”用例是项目流程中的关键节点,由项目管理办公室发起。当立项完成后,一个新的项目就会被创建;项目结项则意味着项目的状态发生变更。如果我们将用例命名为“创建新项目”、“更改项目信息”就不符合项目管理的统一语言。
注意,“通知项目经理”既作为了“评审项目计划”的扩展用例,又作为了“指定项目经理”的扩展用例。当然,在业务上,虽然同为通知,但通知的内容并不相同。在项目管理场景中,所有与通知有关的用例都作为扩展用例出现;事实上,在所有核心领域场景中,通知用例都不是主用例,毕竟它并不参与核心业务。
项目成员的用例图
项目成员与项目经理之间存在泛化关系因为当项目经理在创建编辑一个问题Issue就是作为一名项目成员执行的操作二者的差异还是角色不同导致的权限差异。项目成员的用例图如下所示
我们在识别史诗级故事和主故事时使用了“任务Task”来表达项目管理过程中分配给项目成员的工作而在用例图中我们却改为了“问题Issue”。“问题”是对任务、史诗故事、用户故事、缺陷的一个抽象这是在项目管理领域中得到公认的领域概念。任务这个词语其实是与用户故事User Story、史诗故事Epic、缺陷Defect属于同一等级的概念根据“单一抽象层次原则”使用“任务”进行抽象显然不再合适。
当我们创建一个问题时需要指定问题的基本属性如问题的标题、描述、问题类型等。那么问题所属的迭代、承担人Owner、报告人Reporter是否也作为问题的属性呢我们在设计用例图时确实困惑不已甚至考虑过将上图中“指定问题所属的迭代”与“分配问题给项目成员”用例作为“创建问题”、“编辑问题”的包含用例。经过思索再三最终还是认为这两个用例是有用户目标的即提供了明显的业务价值应该将其作为主用例与项目成员之间存在“使用Use”关系。同样的“更新问题状态”也没有出现在最初的用例图中但实际上它与“编辑问题信息”有着完全不同的用户目标有必要成为项目成员的主用例。
项目经理的用例图
在项目经理用例图中,“指定问题报告人”用例也是出于同样的考虑因素:
在项目经理的用例图中,最初我并没有识别出“跟踪问题进度”用例。后来,我发现我将“查询问题”与“跟踪问题进度”二者混为一谈了,这其实是不正确的。“查询问题”用例是查询符合各种搜索条件的问题,例如查询当前迭代的所有问题,查询当前迭代所有未完成的问题,查询项目成员的所有问题等;“跟踪问题进度”的着眼点是了解当前问题的完成情况,是对进度的跟踪;二者有着不同的用户目标。
服务中心的用例图
“服务中心”也是一个部门作为领域场景的参与者,该参与者的用例非常清晰,就是针对工位和硬件资源的管理:
集团决策者的用例图
最后是“集团决策者”,该参与者的用例主要是查看表达供需关系的统计报表:
就像写真时为求画面的真实准确必须寻找一个唯一的坐标一样绘制用例图的唯一参考坐标就是参与者Actor。每个参与者的用例图或许大小不一粒度不均但自身是完全独立的参与者之间除了存在泛化关系的参与者的用例图互不干扰清晰地勾勒出各自观察视角得到的领域行为。

View File

@ -0,0 +1,132 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
032 实践 先启阶段的领域场景分析(下)
如何有效地识别参与者的用例
前述内容通过用例形式将所有的主故事都转换成了与参与者有关的用例,那么,在识别用例时,是否有什么经验可循呢?
用例关系的确定
一个用例图,往往体现了参与者与用例之间的使用关系,用例与用例之间的包含或扩展关系,有时候还存在用例之间的泛化关系,确定用例之间的关系很重要。在识别用例时,思考参与者与用例之间的关系会成为一个不错的设计起点。尤其在先启阶段,我们识别的用例体现了 Cockburn 提出的用例层次中的用户目标层这恰好对应用例与参与者的“使用Use”关系。从领域场景分析的角度看这个使用关系代表了业务价值。在确定了参与者后你就可以结合主故事与领域场景询问自己“在这个领域场景下该参与者的用户目标是什么”由此可以帮助我们确定该用例是否主用例。
正如对项目用例图中的分析如果考虑编码实现的本质则问题所属迭代、承担人以及问题状态都是问题Issue的属性然而在用例图中我却以“指定问题所属的迭代”、“分配问题给项目成员”、“更新问题状态”此三个主用例与“编辑问题信息”平级因为它们在项目管理中都具有不可替代的业务价值。
与之相反,包含用例与扩展用例是为具有业务价值的主用例提供支持和服务的,识别它们既可以丰富和完善业务逻辑,又可以在后续的用例边界找到属于通用子领域或支撑子领域的业务内容。这些不直接提供业务价值的用例恰好可能组成单独的限界上下文。例如,在前面给出的诸多用例图中,诸如“上传附件”、“通知评估人”等用例主要以扩展用例的形式呈现,这些扩展用例体现了各自内聚的关注点,即文件共享与消息通知。
包含用例与扩展用例之间的区别在于两个用例之间的“粘性”。包含用例为主用例不可缺少之业务环节,如“指定项目经理”包含用例之于“立项”主用例,如果缺少了指定项目经理操作,立项就是不完整的。扩展用例为主用例功能之补充,如“通知立项”扩展用例之于“立项”主用例,即使没有通知立项的相关干系人,也不妨碍立项工作的完成。作为包含用例或扩展用例本身,又可以有属于自己的包含用例或扩展用例,例如“通知项目经理”对“指定项目经理”的扩展:
从功能相关性看,“立项”与“指定项目经理”用例是强相关的,“通知立项”与“立项”用例是弱相关的。因此,对包含和扩展用例的识别往往会影响到后续对限界上下文的识别。
在识别用例图时,还要注意避免错误的用例关系识别。例如,在项目管理用例图中,团队最初为项目成员参与者识别出“接收问题分配”用例。结合业务场景对此进行检验:当项目经理将问题分配给项目成员后,在业务上确乎存在接受问题的行为;但该行为其实是一个线下行为,属于项目成员之间的一个口头表达;当问题分配给项目成员之后,就已经意味着该问题已经被项目成员接受。因此,这个用例是不合理的。
用例名应字斟句酌
在领域场景分析过程中如果我们只满足于用例图的获得无异于买椟还珠。用例图仅仅是我们获得的分析结果但更重要的是我们获得用例图的过程这其中的关键在于团队与领域专家的交流与合作。作为UMLUnified Modeling Language统一建模语言组成部分的用例图已经得到行业的认可。无论是没有技术背景的领域专家还是没有业务背景的技术专家都能很好理解用例图这种可视化的建模语言。
用例名是领域知识的呈现,更是统一语言的有效输入。用例名应采用动宾短语,描述时须字斟句酌,把握每一个动词和名词的精确,动词是领域行为的体现,名词是领域概念的象征,进而这些行为与概念就能再借助领域模型传递给设计模型,最终通过可读性好的代码来体现。当然,在给出中文用例的同时,还应提倡以英文来表述,毕竟在最终的代码层面,还是用英文来“说话”。
在项目管理用例图中我们最初给出的用例为“查看问题完成情况”但在项目管理领域所谓“问题完成情况”仅仅体现了问题的状态却没有清晰地表达问题在迭代周期内的过程。准确的术语是“进度Progress命名为“跟踪问题进度Tracking Issue Progress”更加符合该领域的统一语言。在最初识别用例时对于“创建问题”的包含用例而言最初命名为“问题检查”。这个描述未遵循动宾短语的形式而“检查”一词也容易带来歧义会错以为是项目成员检查问题的完成情况实则是对创建的问题进行合规性验证更名为“验证问题有效性”更为合理。
再以员工管理用例图中的“提交工作日志”为例。企业的内部术语为“日志报工”若以统一语言的角度讲似以“日志报工”用例名为佳。然而在英文中并无“报工”的恰当翻译更为人接受的英文名为“Submit Work Log”因而用例还是应命名为“提交工作日志”。
显然,通过对用例的不断打磨,对存有疑惑的用例,通过可视化的用例图与领域专家不断沟通,借助用例规格的设计指导,可以帮助我们发现问题,并进一步挖掘出准确的领域术语,建立系统的统一语言,并为后续识别限界上下文以及领域建模奠定基础。
识别用例的主题边界
在绘制用例图时除了参与者、用例以及用例之间的关系外还有一个非常重要的要素主题边界Subject Boundary。主题边界包含了一组高内聚的用例并需要设计者为这个边界确定一个主题Subject。显然主题的确定恰好就是对用例的归类至于归类的原则正是[第 3-4 课 识别限界上下文]中提及的两个方面:
语义相关性
功能相关性
语义相关性
通过语义相关性来判别用例是否存在高内聚是一种业务分析手段。就好像我们整理房间一般相同类别的物品会整理放在一处例如衣服类鞋子类书籍类……每个类别其实就是所谓的“主题Subject”。在前面识别用例时我就要求针对用例名要字斟句酌。用例名通常为动宾短语宾语往往体现了领域概念。显然在用例名中如果包含了相同领域概念就可以认为是语义相关的就可能归类到同一个主题中。
在识别用例的主题边界时我抛开了用例图的约束选择将用例图直接以主题边界进行划分不再继续保留参与者与用例、以及用例之间的关系。如下图是对合同Contract主题的识别
这种对用例的可视化方式可以认为是用例图的另一种视图,即“主题视图”,主要表现用例的分类和相关性,属于领域场景分析中的 Where 要素;而之前给出的用例图则为参与者视图,表现了参与者、用例之间的协作关系,属于领域场景分析中的 Who、Why 与 What 要素。两种不同的用例视图可以提供不同的参考价值,同时又保障了用例可视化的清晰度。
仔细分析合同的主题视图我们发现在这个主题边界中的所有用例用例名都包含了“合同Contract”这个领域概念这就是所谓的“语义相关性”。有时候这种语义相关性并没有这么直接需要就领域概念的共同特征进行归纳例如市场Marketing主题
在这个主题中,包括了市场需求、需求订单、客户需求等领域概念,我们却不能分别为其建立主题,毕竟这样建立的主题太过散乱而细碎。这时,就需要针对领域概念,建立抽象,即寻找这些领域概念的共同特征。显然,无论是市场需求,还是经过评估后形成的订单及客户需求,都是为一种更高的抽象层次“市场”服务的。
通过语义相关性判断用例的归属时,一个用例有可能包含两个语义,这时就需要判断语义与主题相关性的强弱。例如,“从储备人才转为正式员工”用例,究竟属于储备人才主题,还是员工主题?判断语义的相关性强弱时,可以依据用例的业务价值或用户目标,应优先考虑满足用户目标的语义。显然,“从储备人才转为正式员工”用例的用户目标是生成员工记录,储备人才的信息仅仅作为该领域行为的输入,答案不言而喻。
功能相关性
领域概念是名词而用例则是动词表达了一种领域行为。在确定用例的主题边界时如果我们发现一些用例虽然在领域概念上没有明显的语义相关性但它们却服务于一个共同的用户目标或业务价值则说明它们是功能相关的。例如考勤Attendance主题
功能相关性还体现于用例之间的关联与依赖,在用例图中,主要以用例关系的包含、扩展与泛化来体现。例如,人事专员用例图:
与员工管理功能相关的子用例包括:
上传员工劳务合同
从储备人才转为正式员工
录入项目经历
录入技能信息
录入语言能力
那么,员工主题就应该包含以上功能相关的用例:
在确定用例之间关系时,我提到了包含与扩展不同的“粘度”。在确定功能相关性时,尤其要特别关注主用例的扩展用例。根据我的经验,大多数扩展用例提供了不同于主业务视角的关注点,而这些关注点往往在支撑子领域场景中提供了共同的业务价值,可以对它们做进一步抽象。在 EAS 中这样的扩展用例主要体现在两个方面文件上传与下载、消息通知故而可以为其分别建立主题边界文件共享File Sharing与通知Notification
注意文件共享与通知主题中的用例名,描述了与具体领域场景无关的通用业务。例如,在招聘专员用例图中,定义了“上传储备人才简历”和“通知招聘计划审核人”两个用例,它们实际上分别对应了“上传 Word 文件”与“发送通知电子邮件”、“发送站内信息”用例。之所以这样描述用例名,是因为这两个主题可能会为通过其他主题提供业务支撑,一旦具体化,就无法满足通用要求。
文件共享和通知主题中的用例并没有出现在之前识别的用例图中。通过参与者识别用例图时,我们是根据先启阶段识别出来的核心业务流程、史诗级故事与主故事,并通过设想参与者参与的领域场景,进而驱动得出这些用例。文件共享和通知主题中的用例则是通过寻找所有的扩展用例,进而归纳出它们的共同特征。这是两种迥然不同的用例分析方法。
设计的决策
无论是寻找领域概念的共同特征还是识别用例行为的用户目标都需要一种抽象能力。在进行抽象时可能出现“向左走还是向右走”的困惑。这是因为抽象的层次可能不同抽象的方向或依据亦有所不同这时就需要做出设计上的决策。例如针对用例中识别出来的“员工”与“储备人才”领域概念我们可以抽象出“人才”的共同特征从而得到人才Talent主题
然而从共同的用户目标考虑储备人才又是服务于招聘和面试的似乎归入招聘Recruiting主题才是合理的选择
实际上还有第三种选择就是将储备人才单独抽离出来形成自己的“储备人才Candidate”主题
该如何抉择呢?我认为须得思考为什么要识别主题边界?显然,这里识别的主题边界仅仅是设计过程中的中间产物,并非我们最终的设计目标。主题边界是对用例的分类,在用例图中体现了用例的边界,而这种边界恰好可以对应领域模型的限界上下文,并为设计模型的包、模块提供设计指导。因此,究竟为人才主题,还是招聘主题,或者单独的储备人才主题,完全可以从限界上下文或者领域建模的角度去思考。
主题边界体现用例的内聚性
主题边界并不以边界内用例的多寡为设计准则。至少在进行领域场景分析时不要因为一个主题边界包含了太多的用例就人为地对其进行更细粒度的拆分关键还是要考察用例的内聚性。例如与项目管理有关的主题包含的用例数量就非常不均匀。项目Project主题包含的用例为
问题Issue主题包含的用例为
项目成员Project Member主题包含的用例最少
识别主题边界不是求平衡,更不是为了让设计的模型更加好看,它的设计质量可能会直接影响到后续的限界上下文识别。或许内聚性的识别需要较强的分析能力和抽象能力,但只要我们遵循领域场景分析的设计思想,按部就班地通过业务流程识别参与者,再根据参与者驱动出清晰表达的用例图,最后再根据语义相关性和功能相关性识别主题边界,就能获得一个相对不错的场景分析结果。毕竟,这个分析过程是有章可循的,在知识的积累上也是层层递进的。整个过程不需要任何与技术实现有关的知识,非常利于领域专家与团队的共同协作和交流。