diff --git a/专栏/陈天·Rust编程第一课/用户故事语言不仅是工具,还是思维方式.md b/专栏/陈天·Rust编程第一课/用户故事语言不仅是工具,还是思维方式.md new file mode 100644 index 0000000..e28cdd2 --- /dev/null +++ b/专栏/陈天·Rust编程第一课/用户故事语言不仅是工具,还是思维方式.md @@ -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, + 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, Box> { + 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::(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 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 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》 +《硅谷来信》 +《浪潮之巅》 + + + + + \ No newline at end of file diff --git a/专栏/陈天·Rust编程第一课/结束语永续之原:Rust学习,如何持续精进?.md b/专栏/陈天·Rust编程第一课/结束语永续之原:Rust学习,如何持续精进?.md new file mode 100644 index 0000000..6b02a2c --- /dev/null +++ b/专栏/陈天·Rust编程第一课/结束语永续之原:Rust学习,如何持续精进?.md @@ -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 { + // 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 = ""; + + 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: +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 +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: + + +所以,很多时候,知识就在我们身边,我们写一写代码就能获取。 + +在这个过程中,你自己思考之后撰写的探索性的代码、你分析输出过程中付出的思考和深度的阅读,以及最后在梳理过程中进行的总结,都会让知识牢牢变成你自己的。 + +最后我们聊一聊写代码这个事。 + +学习任何语言,最重要的步骤都是用学到的知识,解决实际的问题。Rust 能不能胜任你需要完成的各种任务?大概率能。但你能不能用 Rust 来完成这些任务?不一定。每个十指俱全的人都能学习弹钢琴,但不是每个学弹钢琴的人都能达到十级的水平。这其中现实和理想间巨大的鸿沟就是“刻意练习”。 + +想要成为 Rust 专家,想让 Rust 成为你职业生涯中的一项重要技能,刻意练习必不可少,需要不断地撰写代码。的确,Rust 的所有权和生命周期学习和使用起来让人难于理解,所有权、生命周期,跟类型系统(包括泛型、trait),以及异步开发结合起来,更是障碍重重,但通过不断学习和不断练习,你一定会发现,它们不过是你的一段伟大旅程中越过的一个小山丘而已。 + +最后的最后,估计很多同学都是在艰难斗争、默默学习,在专栏要结束的今天,欢迎你在留言区留言,我非常希望能听到你的声音,听听你学习这个专栏的感受和收获,见到你的身影。点这里还可以提出你对课程的反馈与建议。 + +感谢你选择我的 Rust 第一课。感谢你陪我们一路走到这里。接下来,就看你的了。 + + +“Go where you must go, and hope!”— Gandalf + + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/00开篇词入门Spark,你需要学会“三步走”.md b/专栏/零基础入门Spark/00开篇词入门Spark,你需要学会“三步走”.md new file mode 100644 index 0000000..8e6e8db --- /dev/null +++ b/专栏/零基础入门Spark/00开篇词入门Spark,你需要学会“三步走”.md @@ -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)将Databricks(Spark云原生商业版本)提名为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,还要先学Scala,Scala语法晦涩难懂,直接劝退; +开发算子太多了,记不住,来了新的业务需求,不知道该从哪里下手; +…… + + +既然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为你的职业发展增光添彩! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/01Spark:从“大数据的HelloWorld”开始.md b/专栏/零基础入门Spark/01Spark:从“大数据的HelloWorld”开始.md new file mode 100644 index 0000000..7d70830 --- /dev/null +++ b/专栏/零基础入门Spark/01Spark:从“大数据的HelloWorld”开始.md @@ -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提供交互式的运行环境(REPL,Read-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实例(Instance),SparkSession在spark-shell中会由系统自动创建; +sparkContext是开发入口SparkContext实例。 + + +在Spark版本演进的过程中,从2.0版本开始,SparkSession取代了SparkContext,成为统一的开发入口。换句话说,要开发Spark应用,你必须先创建SparkSession。关于SparkSession和SparkContext,我会在后续的课程做更详细的介绍,这里你只要记住它们是必需的开发入口就可以了。 + +我们再来看看RDD,RDD的全称是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)类型的数据元素,也就是(Key,Value)形式的“数组”元素。 + +因此,在调用聚合算子做分组计数之前,我们要先把RDD元素转换为(Key,Value)的形式,也就是把RDD[String]映射成RDD[(String, Int)]。 + +其中,我们统一把所有的Value置为1。这样一来,对于同一个的单词,在后续的计数运算中,我们只要对Value做累加即可,就像这样: + + + +下面是对应的代码: + +// 把RDD元素转换为(Key,Value)的形式 +val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1)) + + +这样一来,RDD就由原来存储String元素的cleanWordRDD,转换为了存储(String,Int)的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元素转换为(Key,Value)的形式 +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算子吗?(提示,可以结合官网去查找)。 + +另外,你能说说,以上这些算子都有哪些共性或是共同点吗? + +欢迎你把答案分享到评论区,我在评论区等你。 + +如果这一讲对你有帮助,也欢迎你分享给自己的朋友,我们下一讲再见! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/02RDD与编程模型:延迟计算是怎么回事?.md b/专栏/零基础入门Spark/02RDD与编程模型:延迟计算是怎么回事?.md new file mode 100644 index 0000000..413218e --- /dev/null +++ b/专栏/零基础入门Spark/02RDD与编程模型:延迟计算是怎么回事?.md @@ -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:分片切割规则 +dependencies:RDD依赖 +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元素转换为(Key,Value)的形式 +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又转换成元素为(Key,Value)对的kvRDD;- +最终,我们调用reduceByKey做分组聚合,把kvRDD中的Value从1转换为单词计数。 + +这4步转换的过程如下图所示: + + + +我们刚刚说过,RDD代表的是分布式数据形态,因此,RDD到RDD之间的转换,本质上是数据形态上的转换(Transformations)。 + +在RDD的编程模型中,一共有两种算子,Transformations类算子和Actions类算子。开发者需要使用Transformations类算子,定义并描述数据形态的转换过程,然后调用Actions类算子,将计算结果收集起来、或是物化到磁盘。 + +在这样的编程模型下,Spark在运行时的计算被划分为两个环节。 + + +基于不同数据形态之间的转换,构建计算流图(DAG,Directed 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:分片切割规则 +dependencies:RDD依赖 +compute:转换函数 + + +深入理解RDD之后,你需要熟悉RDD的编程模型。在RDD的编程模型中,开发者需要使用Transformations类算子,定义并描述数据形态的转换过程,然后调用Actions类算子,将计算结果收集起来、或是物化到磁盘。 + +而延迟计算指的是,开发者调用的各类Transformations算子,并不会立即执行计算,当且仅当开发者调用Actions算子时,之前调用的转换算子才会付诸执行。 + +每课一练 + +对于Word Count的计算流图与土豆工坊的流水线工艺,尽管看上去毫不相关,风马牛不相及,不过,你不妨花点时间想一想,它们之间有哪些区别和联系? + +欢迎你把答案分享到评论区,我在评论区等你,也欢迎你把这一讲分享给更多的朋友和同事,我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/03RDD常用算子(一):RDD内部的数据转换.md b/专栏/零基础入门Spark/03RDD常用算子(一):RDD内部的数据转换.md new file mode 100644 index 0000000..e1f6067 --- /dev/null +++ b/专栏/零基础入门Spark/03RDD常用算子(一):RDD内部的数据转换.md @@ -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算子的用法:给定映射函数f,map(f)以元素为粒度对RDD做数据转换。其中f可以是带有明确签名的带名函数,也可以是匿名函数,它的形参类型必须与RDD的元素类型保持一致,而输出类型则任由开发者自行决定。 + +这种照本宣科的介绍听上去难免会让你有点懵,别着急,接下来我们用些小例子来更加直观地展示map的用法。 + +在[第一讲]的Word Count示例中,我们使用如下代码,把包含单词的RDD转换成元素为(Key,Value)对的RDD,后者统称为Paired RDD。 + +// 把普通RDD转换为Paired RDD +val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码 +val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1)) + + +在上面的代码实现中,传递给map算子的形参,即:word => (word,1),就是我们上面说的映射函数f。只不过,这里f是以匿名函数的方式进行定义的,其中左侧的word表示匿名函数f的输入形参,而右侧的(word,1)则代表函数f的输出结果。 + +如果我们把匿名函数变成带名函数的话,可能你会看的更清楚一些。这里我用一段代码重新定义了带名函数f。 + +// 把RDD元素转换为(Key,Value)的形式 + +// 定义映射函数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元素转换为(Key,Value)的形式 + +// 定义映射函数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这对“孪生兄弟”就是用来解决类似的问题。相比mapPartitions,mapPartitionsWithIndex仅仅多出了一个数据分区索引,因此接下来我们把重点放在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的文件系统句柄,再比如用于在线推理的机器学习模型,等等,不一而足。你不妨结合实际工作场景,把你遇到的共享操作整理到留言区,期待你的分享。 + +相比mapPartitions,mapPartitionsWithIndex仅仅多出了一个数据分区索引,这个数据分区索引可以为我们获取分区编号,当你的业务逻辑中需要使用到分区编号的时候,不妨考虑使用这个算子来实现代码。除了这个额外的分区索引以外,mapPartitionsWithIndex在其他方面与mapPartitions是完全一样的。 + +介绍完map与mapPartitions算子之后,接下来,我们趁热打铁,再来看一个与这两者功能类似的算子:flatMap。 + +flatMap:从元素到集合、再从集合到元素 + +flatMap其实和map与mapPartitions算子类似,在功能上,与map和mapPartitions一样,flatMap也是用来做数据映射的,在实现上,对于给定映射函数f,flatMap(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做各式各样的数据转换,给定映射函数f,map(f)以元素为粒度对RDD做数据转换。其中f可以是带名函数,也可以是匿名函数,它的形参类型必须与RDD的元素类型保持一致,而输出类型则任由开发者自行决定。 + +为了提升数据转换的效率,Spark提供了以数据分区为粒度的mapPartitions算子。mapPartitions的形参是代表数据分区的partition,它通过在partition之上再次调用map(f)来完成数据的转换。相比map,mapPartitions的优势是以数据分区为粒度初始化共享对象,这些共享对象在我们日常的开发中很常见,比如数据库连接对象、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中初始化的共享对象呢? + + +欢迎你在评论区回答这些练习题。你也可以把这一讲分享给更多的朋友或者同事,和他们一起讨论讨论,交流是学习的催化剂。我在评论区等你。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/04进程模型与分布式部署:分布式计算是怎么回事?.md b/专栏/零基础入门Spark/04进程模型与分布式部署:分布式计算是怎么回事?.md new file mode 100644 index 0000000..cc1a275 --- /dev/null +++ b/专栏/零基础入门Spark/04进程模型与分布式部署:分布式计算是怎么回事?.md @@ -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在这些独立框架下的分布式部署,都需要哪些必备的步骤? + + +今天这一讲就到这里了,如果你在部署过程中遇到的什么问题,欢迎你在评论区提问。如果你觉得这一讲帮助到了你,也欢迎你分享给更多的朋友和同事,我们下一讲再见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/05调度系统:如何把握分布式计算的精髓?.md b/专栏/零基础入门Spark/05调度系统:如何把握分布式计算的精髓?.md new file mode 100644 index 0000000..9d4b925 --- /dev/null +++ b/专栏/零基础入门Spark/05调度系统:如何把握分布式计算的精髓?.md @@ -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拆分为执行阶段Stages,Stages指的是不同的运行阶段,同时还要负责把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。 + +对于提请执行的每一个Stage,DAGScheduler根据Stage内RDD的partitions属性创建分布式任务集合TaskSet。TaskSet包含一个又一个分布式任务Task,RDD有多少数据分区,TaskSet就包含多少个Task。换句话说,Task与RDD的分区,是一一对应的。 + +你可能会问:“Task代表的是分布式任务,不过它具体是什么呢?”要更好地认识Task,我们不妨来看看它的关键属性。 + + + +在上表中,stageId、stageAttemptId标记了Task与执行阶段Stage的所属关系;taskBinary则封装了隶属于这个执行阶段的用户代码;partition就是我们刚刚说的RDD数据分区;locs属性以字符串的形式记录了该任务倾向的计算节点或是Executor ID。 + +不难发现,taskBinary、partition和locs这三个属性,一起描述了这样一件事情:Task应该在哪里(locs)为谁(partition)执行什么任务(taskBinary)。 + +到这里,我们讲完了戴格的职责,让我们来一起简单汇总一下,戴格指代的是DAGScheduler,DAGScheduler的主要职责有三个: + + +根据用户代码构建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提供的一个个WorkerOffer,TaskScheduler是依据什么规则来挑选Tasks的呢? + +用一句话来回答,对于给定的WorkerOffer,TaskScheduler是按照任务的本地倾向性,来遴选出TaskSet中适合调度的Tasks。这是什么意思呢?听上去比较抽象,我们还是从DAGScheduler在Stage内创建任务集TaskSet说起。 + +我们刚刚说过,Task与RDD的partitions是一一对应的,在创建Task的过程中,DAGScheduler会根据数据分区的物理地址,来为Task设置locs属性。locs属性记录了数据分区所在的计算节点、甚至是Executor进程ID。 + +举例来说,当我们调用textFile API从HDFS文件系统中读取源文件时,Spark会根据HDFS NameNode当中记录的元数据,获取数据分区的存储地址,例如node0:/rootPath/partition0-replica0,node1:/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、再到ANY,Task的本地性倾向逐渐从严苛变得宽松。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为粒度提供计算资源。- +对于给定WorkerOffer,TaskScheduler结合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? + +欢迎你在评论区回答这个问题。如果你觉得这一讲对你有所帮助,也欢迎你把它分享给更多的朋友和同事。我在评论区等你,咱们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/06Shuffle管理:为什么Shuffle是性能瓶颈?.md b/专栏/零基础入门Spark/06Shuffle管理:为什么Shuffle是性能瓶颈?.md new file mode 100644 index 0000000..8cd5cfd --- /dev/null +++ b/专栏/零基础入门Spark/06Shuffle管理:为什么Shuffle是性能瓶颈?.md @@ -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中间文件是统称、泛指,它包含两类实体文件,一个是记录(Key,Value)键值对的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 ID,Record Key),而Value是原数据记录中的数据值,如图中的“内存数据结构”所示。 + +对于数据分区中的数据记录,Spark会根据我们前面提到的公式1逐条计算记录所属的目标分区ID,然后把主键(Reduce Task Partition ID,Record Key)和记录的数据值插入到Map数据结构中。当Map结构被灌满之后,Spark根据主键对Map中的数据记录做排序,然后把所有内容溢出到磁盘中的临时文件,如图中的步骤1所示。 + +随着Map结构被清空,Spark可以继续读取分区内容并继续向Map结构中插入数据,直到Map结构再次被灌满而再次溢出,如图中的步骤2所示。就这样,如此往复,直到数据分区中所有的数据记录都被处理完毕。 + +到此为止,磁盘上存有若干个溢出的临时文件,而内存的Map结构中留有部分数据,Spark使用归并排序算法对所有临时文件和Map结构剩余数据做合并,分别生成data文件、和与之对应的index文件,如图中步骤4所示。Shuffle阶段生成中间文件的过程,又叫Shuffle Write。 + +总结下来,Shuffle中间文件的生成过程,分为如下几个步骤: + + +对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;- +当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;- +重复前 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中间文件是统称,它包含两类文件,一个是记录(Key,Value)键值对的data文件,另一个是记录键值对所属Reduce Task的index文件。计算图DAG中的Map阶段与Reduce阶段,正是通过中间文件来完成数据的交换。 + +接下来,我们详细讲解了Shuffle Write过程中生成中间文件的详细过程,归纳起来,这个过程分为4个步骤: + + +对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;- +当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 ID,Key)排序,将所有数据溢出到临时文件,同时清空数据结构;- +重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;- +对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。 + + +最后,在Reduce阶段,Reduce Task通过index文件来“定位”属于自己的数据内容,并通过网络从不同节点的data文件中下载属于自己的数据记录。 + +每课一练 + +这一讲就到这里了,我在这给你留个思考题: + +在Shuffle的计算过程中,中间文件存储在参数spark.local.dir设置的文件目录中,这个参数的默认值是/tmp,你觉得这个参数该如何设置才更合理呢? + +欢迎你在评论区分享你的答案,我在评论区等你。如果这一讲对你有所帮助,你也可以分享给自己的朋友,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/07RDD常用算子(二):Spark如何实现数据聚合?.md b/专栏/零基础入门Spark/07RDD常用算子(二):Spark如何实现数据聚合?.md new file mode 100644 index 0000000..e2398b9 --- /dev/null +++ b/专栏/零基础入门Spark/07RDD常用算子(二):Spark如何实现数据聚合?.md @@ -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,它指的是元素类型为(Key,Value)键值对的RDD。 + +但是在功能方面,可以说,它们承担了数据分析场景中的大部分职责。因此,掌握这些算子的用法,是我们能够游刃有余地开发数据分析应用的重要基础。那么接下来,我们就通过一些实例,来熟悉并学习这些算子的用法。 + +我们先来说说groupByKey,坦白地说,相比后面的3个算子,groupByKey在我们日常开发中的“出镜率”并不高。之所以要先介绍它,主要是为后续的reduceByKey和aggregateByKey这两个重要算子做铺垫。 + +groupByKey:分组收集 + +groupByKey的字面意思是“按照Key做分组”,但实际上,groupByKey算子包含两步,即分组和收集。 + +具体来说,对于元素类型为(Key,Value)键值对的Paired RDD,groupByKey的功能就是对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 => (word,1)变更为word => (word,word),这么做的效果,是把kvRDD元素的Key和Value都变成了单词。 + +紧接着,第二个改动,我们用groupByKey替换了原先的reduceByKey。相比reduceByKey,groupByKey的用法要简明得多。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的“升级版”,相比reduceByKey,aggregateByKey用法更加灵活,支持的功能也更加完备。 + +接下来,我们先来回顾reduceByKey,然后再对aggregateByKey进行展开。相比aggregateByKey,combineByKey仅在初始化方式上有所不同,因此,我把它留给你作为课后作业去探索。 + +reduceByKey:分组聚合 + +reduceByKey的字面含义是“按照Key值做聚合”,它的计算逻辑,就是根据聚合函数f给出的算法,把Key值相同的多个元素,聚合成一个元素。 + +在[第1讲]Word Count的实现中,我们使用了reduceByKey来实现分组计数: + +// 把RDD元素转换为(Key,Value)的形式 +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元素转换为(Key,Value)的形式 +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聚合又能怎样呢?相比groupByKey,reduceByKey带来的性能收益并不算明显呀!”确实,就上面的示意图来说,我们很难感受到reduceByKey带来的性能收益。不过,量变引起质变,在工业级的海量数据下,相比groupByKey,reduceByKey通过在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端聚合函数f2,aggregateByKey的调用形式如下所示: + +val rdd: RDD[(Key类型,Value类型)] = _ +rdd.aggregateByKey(初始值)(f1, f2) + + +初始值可以是任意数值或是字符串,而聚合函数我们也不陌生,它们都是带有两个形参和一个输出结果的普通函数。就这3个参数来说,比较伤脑筋的,是它们之间的类型需要保持一致,具体来说: + + +初始值类型,必须与f2的结果类型保持一致; +f1的形参类型,必须与Paired RDD的Value类型保持一致; +f2的形参类型,必须与f1的结果类型保持一致。 + + +不同类型之间的一致性描述起来比较拗口,咱们不妨结合示意图来加深理解: + + + +熟悉了aggregateByKey的用法之后,接下来,我们用aggregateByKey这个算子来实现刚刚提到的“先加和,再取最大值”的计算逻辑,代码实现如下所示: + +// 把RDD元素转换为(Key,Value)的形式 +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进行排序”。给定包含(Key,Value)键值对的Paired RDD,sortByKey会以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算子。 + +利用聚合函数f,reduceByKey可以在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的功能吗? + +欢迎你分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,和他一起来讨论一下本讲的练习题,我们下一讲再见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/08内存管理:Spark如何使用内存?.md b/专栏/零基础入门Spark/08内存管理:Spark如何使用内存?.md new file mode 100644 index 0000000..4a06953 --- /dev/null +++ b/专栏/零基础入门Spark/08内存管理:Spark如何使用内存?.md @@ -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元素转换为(Key,Value)的形式 +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函数实际上会进一步调用persist(MEMORY_ONLY)来完成计算。换句话说,下面的两条语句是完全等价的,二者的含义都是把RDD物化到内存。 + +wordCounts.cache +wordCounts.persist(MEMORY_ONLY) + + +就添加Cache来说,相比cache算子,persist算子更具备普适性,结合多样的存储级别(如这里的MEMORY_ONLY),persist算子允许开发者灵活地选择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)的空间大小。 + + + +欢迎你在评论区分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,我们下一讲再见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/09RDD常用算子(三):数据的准备、重分布与持久化.md b/专栏/零基础入门Spark/09RDD常用算子(三):数据的准备、重分布与持久化.md new file mode 100644 index 0000000..f5d24b1 --- /dev/null +++ b/专栏/零基础入门Spark/09RDD常用算子(三):数据的准备、重分布与持久化.md @@ -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这个操作。 + +具体怎么使用呢?我来举个例子。给定两个RDD:rdd1和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个RDD:rdd1、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,我该怎么办呢?”如果你想增加并行度,那我们还真的只能仰仗repartition,Shuffle的问题自然也就无法避免。但假设你的需求是降低并行度,这个时候,我们就可以把目光投向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之上调用toDebugString,Spark可以帮我们打印出当前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元素转换为(Key,Value)的形式 +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) + +相比repartition,coalesce有哪些可能的潜在隐患?(提示:数据分布) + + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多的同事、朋友,帮他理清RDD的常用算子。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/10广播变量&累加器:共享变量是用来做什么的?.md b/专栏/零基础入门Spark/10广播变量&累加器:共享变量是用来做什么的?.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/零基础入门Spark/11存储系统:数据到底都存哪儿了?.md b/专栏/零基础入门Spark/11存储系统:数据到底都存哪儿了?.md new file mode 100644 index 0000000..89c84a0 --- /dev/null +++ b/专栏/零基础入门Spark/11存储系统:数据到底都存哪儿了?.md @@ -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是BlockId,Value是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; +将(BlockId,MemoryEntry)键值对添加到“小册子”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这种数据结构的特点与特性。 + +期待在留言区看到你的思考。如果这一讲对你有帮助,也推荐你转发给更多的同事、朋友。我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/12基础配置详解:哪些参数会影响应用程序稳定性?.md b/专栏/零基础入门Spark/12基础配置详解:哪些参数会影响应用程序稳定性?.md new file mode 100644 index 0000000..53c0623 --- /dev/null +++ b/专栏/零基础入门Spark/12基础配置详解:哪些参数会影响应用程序稳定性?.md @@ -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 Memory,Spark将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的设置,开发者该如何进行取舍呢?答案是看数据的复用频次。这是什么意思呢?我们分场景举例来说。 + +对于ETL(Extract、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 Cores(CPU核数)。 + +我们知道,一个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这个配置项的作用之后,我们自然就能想到,应该把它设置到一个存储空间充沛、甚至性能更有保障的文件系统,比如空间足够大的SSD(Solid State Disk)文件系统目录。 + +好啦,到此为止,我们分别介绍了与CPU、内存、磁盘有关的配置项,以及它们的含义、作用与设置技巧。说到这里,你可能有些按捺不住:“这些配置项的重要性我已经get到了,那我应该在哪里设置它们呢?”接下来,我们继续来说说,开发者都可以通过哪些途径来设置配置项。 + +配置项的设置途径 + +为了满足不同的应用场景,Spark为开发者提供了3种配置项设置方式,分别是配置文件、命令行参数和SparkConf对象,这些方式都以(Key,Value)键值对的形式记录并设置配置项。 + +配置文件指的是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对象的方式来设置比较好? + +欢迎你在留言区跟我交流。如果这一讲对你有帮助的话,也推荐你把这节课分享给有需要的的同事、朋友,我们下一讲见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/13SparkSQL:让我们从“小汽车摇号分析”开始.md b/专栏/零基础入门Spark/13SparkSQL:让我们从“小汽车摇号分析”开始.md new file mode 100644 index 0000000..0daef7b --- /dev/null +++ b/专栏/零基础入门Spark/13SparkSQL:让我们从“小汽车摇号分析”开始.md @@ -0,0 +1,222 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 13 Spark SQL:让我们从“小汽车摇号分析”开始 + 你好,我是吴磊。 + +在开篇词我们提出“入门Spark需要三步走”,到目前为止,我们携手并肩跨越了前面两步,首先恭喜你学到这里!熟练掌握了Spark常用算子与核心原理以后,你已经可以轻松应对大部分数据处理需求了。 + +不过,数据处理毕竟是比较基础的数据应用场景,就像赛车有着不同的驾驶场景,想成为Spark的资深赛车手,我们还要走出第三步——学习Spark计算子框架。只有完成这一步,我们才能掌握Spark SQL,Structured 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 + + +看到这里,想必你已经眉头紧锁:“SparkSession?DataFrame?这些都是什么鬼?你好像压根儿也没有提到过这些概念呀!”别着急,对于这些关键概念,我们在后续的课程中都会陆续展开,今天这一讲,咱们先来“知其然”,“知其所以然”的部分咱们放到后面去讲。 + +对于SparkSession,你可以把它理解为是SparkContext的进阶版,是Spark(2.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算子把(batchNum,carNum)出现的次数,作为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一致。 + + +欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的朋友、同事。我们下一讲见! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/14台前幕后:DataFrame与SparkSQL的由来.md b/专栏/零基础入门Spark/14台前幕后:DataFrame与SparkSQL的由来.md new file mode 100644 index 0000000..cbd911e --- /dev/null +++ b/专栏/零基础入门Spark/14台前幕后:DataFrame与SparkSQL的由来.md @@ -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。那么,相比RDD,DataFrame到底有何不同呢?我们不妨从两个方面来对比它们的不同:一个是数据的表示形式(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的计算过程。 + +总结下来,相比RDD,DataFrame通过携带明确类型信息的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去做执行。 + +弄清二者的关系与定位之后,接下来的问题是:“基于DataFrame,Spark 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生成抽象语法树(AST,Abstract 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种实现方式,分别是嵌套循环连接(NLJ,Nested 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的设计与实现,执行代码优化则指的是全阶段代码生成(WSCG,Whole 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算子,都白学了呢? + + + +欢迎你在留言区和我交流讨论,也推荐你把这一讲的内容分享给更多朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/15数据源与数据格式:DataFrame从何而来?.md b/专栏/零基础入门Spark/15数据源与数据格式:DataFrame从何而来?.md new file mode 100644 index 0000000..47e36f1 --- /dev/null +++ b/专栏/零基础入门Spark/15数据源与数据格式:DataFrame从何而来?.md @@ -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)上,相比RDD,DataFrame仅仅是多了一个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用于定义并封装Schema,StructFiled用于定义Schema中的每一个字段,包括字段名、字段类型,而像StringType、IntegerType这些*Type类型,表示的正是字段类型。为了和RDD数据类型保持一致,Schema对应的元素类型应该是(StringType,IntegerType)。 + +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类参数文件格式,它就是文件的存储格式,如CSV(Comma 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 + + +欢迎你在留言区跟我交流活动,也推荐你把这一讲的内容分享给更多的同事、朋友,跟他一起学习进步。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/16数据转换:如何在DataFrame之上做数据处理?.md b/专栏/零基础入门Spark/16数据转换:如何在DataFrame之上做数据处理?.md new file mode 100644 index 0000000..a5e937f --- /dev/null +++ b/专栏/零基础入门Spark/16数据转换:如何在DataFrame之上做数据处理?.md @@ -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计算呢? + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给有需要的朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/17数据关联:不同的关联形式与实现机制该怎么选?.md b/专栏/零基础入门Spark/17数据关联:不同的关联形式与实现机制该怎么选?.md new file mode 100644 index 0000000..511756d --- /dev/null +++ b/专栏/零基础入门Spark/17数据关联:不同的关联形式与实现机制该怎么选?.md @@ -0,0 +1,323 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 17 数据关联:不同的关联形式与实现机制该怎么选? + 你好,我是吴磊。 + +在上一讲,我们学习了Spark SQL支持的诸多算子。其中数据关联(Join)是数据分析场景中最常见、最重要的操作。毫不夸张地说,几乎在所有的数据应用中,你都能看到数据关联的“身影”。因此,今天这一讲,咱们继续详细说一说Spark SQL对于Join的支持。 + +众所周知,Join的种类非常丰富。如果按照关联形式(Join Types)来划分,数据关联分为内关联、外关联、左关联、右关联,等等。对于参与关联计算的两张表,关联形式决定了结果集的数据来源。因此,在开发过程中选择哪种关联形式,是由我们的业务逻辑决定的。 + +而从实现机制的角度,Join又可以分为NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和HJ(Hash 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种实现机制,分别是NLJ(Nested Loop Join)、SMJ(Sort Merge Join)和HJ(Hash 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| ++---+------+---+-------+---+------+ +*/ + + +NLJ:Nested Loop Join + +对于参与关联的两张表,如salaries和employees,按照它们在代码中出现的顺序,我们约定俗成地把salaries称作“左表”,而把employees称作“右表”。在探讨关联机制的时候,我们又常常把左表称作是“驱动表”,而把右表称为“基表”。 + +一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。 + +在NLJ的实现机制下,算法会使用外、内两个嵌套的for循环,来依次扫描驱动表与基表中的数据记录。在扫描的同时,还会判定关联条件是否成立,如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。 + + + +在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录,依次判断记录的id字段值是否满足关联条件,如步骤 2 所示。 + +不难发现,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管NLJ的实现方式简单、直观、易懂,但它的执行效率显然很差。 + +SMJ:Sort 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的计算过程我们可以用“先苦后甜”来形容。苦,指的是要先花费时间给两张表做排序,而甜,指的则是有序表的归并关联能够享受到线性的计算复杂度。 + +HJ:Hash Join + +考虑到SMJ对于排序的苛刻要求,后来又有人推出了HJ算法。HJ的设计初衷是以空间换时间,力图将基表扫描的计算复杂度降低至O(1)。 + + + +具体来说,HJ的计算分为两个阶段,分别是Build阶段和Probe阶段。在Build阶段,在基表之上,算法使用既定的哈希函数构建哈希表,如上图的步骤 1 所示。哈希表中的Key是id字段应用(Apply)哈希函数之后的哈希值,而哈希表的 Value 同时包含了原始的Join Key(id字段)和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,结合其实现原理,你能猜一猜,它们可能的适用场景都有哪些吗?或者换句话说,在什么样的情况下,更适合使用哪种实现机制来进行数据关联? + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给身边的同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/18数据关联优化:都有哪些Join策略,开发者该如何取舍?.md b/专栏/零基础入门Spark/18数据关联优化:都有哪些Join策略,开发者该如何取舍?.md new file mode 100644 index 0000000..6e1faee --- /dev/null +++ b/专栏/零基础入门Spark/18数据关联优化:都有哪些Join策略,开发者该如何取舍?.md @@ -0,0 +1,143 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 18 数据关联优化:都有哪些Join策略,开发者该如何取舍? + 你好,我是吴磊。 + +在上一讲,我们分别从关联形式与实现机制这两个方面,对数据分析进行了讲解和介绍。对于不同关联形式的用法和实现机制的原理,想必你已经了然于胸。不过,在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。 + +我们知道,在分布式环境中,Spark支持两类数据分发模式。一类是我们在[第7讲]学过的Shuffle,Shuffle通过中间文件来完成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 Join,Broadcast 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 Join),Spark SQL优先考虑采用Broadcast HJ策略,其次是Shuffle SMJ,最次是Shuffle HJ。对于不等值关联(Non Equi Join),Spark 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的执行效率入手做分析。 + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/19配置项详解:哪些参数会影响应用程序执行性能?.md b/专栏/零基础入门Spark/19配置项详解:哪些参数会影响应用程序执行性能?.md new file mode 100644 index 0000000..8d79b86 --- /dev/null +++ b/专栏/零基础入门Spark/19配置项详解:哪些参数会影响应用程序执行性能?.md @@ -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策略的重要性不必多说,AQE(Adaptive 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策略来完成关联计算。 + +然后,我们分别介绍了AQE(Adaptive 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的两个计算阶段出发,去思考这个问题) + +欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多的同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/20Hive+Spark强强联合:分布式数仓的不二之选.md b/专栏/零基础入门Spark/20Hive+Spark强强联合:分布式数仓的不二之选.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/零基础入门Spark/21SparkUI(上):如何高效地定位性能问题?.md b/专栏/零基础入门Spark/21SparkUI(上):如何高效地定位性能问题?.md new file mode 100644 index 0000000..78d5805 --- /dev/null +++ b/专栏/零基础入门Spark/21SparkUI(上):如何高效地定位性能问题?.md @@ -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个Actions:save(保存计算结果)、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? + +欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/22SparkUI(下):如何高效地定位性能问题?.md b/专栏/零基础入门Spark/22SparkUI(下):如何高效地定位性能问题?.md new file mode 100644 index 0000000..31a721a --- /dev/null +++ b/专栏/零基础入门Spark/22SparkUI(下):如何高效地定位性能问题?.md @@ -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,为数据关联做准备。 + + + +可以看到,对于每一个Exchange,Spark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read,从数据量到处理时间,应有尽有。为了方便说明,对于Metrics的解释与释义,我以表格的方式进行了整理,供你随时查阅。 + + + +结合这份Shuffle的“体检报告”,我们就能以量化的方式,去掌握Shuffle过程的计算细节,从而为调优提供更多的洞察与思路。 + +为了让你获得直观感受,我还是举个例子说明。比方说,我们观察到过滤之后的中签编号数据大小不足10MB(7.4MB),这时我们首先会想到,对于这样的大表Join小表,Spark SQL选择了SortMergeJoin策略是不合理的。 + +基于这样的判断,我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法,想必不用我多说,你也早已心领神会:要么用强制广播,要么利用Spark 3.x版本提供的AQE特性。 + +你不妨结合本讲开头的代码,去完成SortMergeJoin到BroadcastHashJoin策略转换的调优,期待你在留言区分享你的调优结果。 + +Sort + +接下来,我们再来说说Sort。相比Exchange,Sort的度量指标没那么多,不过,他们足以让我们一窥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 / Records,Shuffle Remote Reads,等等。 + +这些Metrics我们在介绍SQL详情的时候,已经详细说过了。另外,Duration、GC Time、以及Peak Execution Memory,这些Metrics的含义,要么已经讲过,要么过于简单、无需解释。因此,对于这3个指标,咱们也不再多着笔墨。 + +这里特别值得你关注的,是Spill(Memory)和Spill(Disk)这两个指标。Spill,也即溢出数据,它指的是因内存数据结构(PartitionedPairBuffer、AppendOnlyMap,等等)空间受限,而腾挪出去的数据。Spill(Memory)表示的是,这部分数据在内存中的存储大小,而Spill(Disk)表示的是,这些数据在磁盘中的大小。 + +因此,用Spill(Memory)除以Spill(Disk),就可以得到“数据膨胀系数”的近似值,我们把它记为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使用的心得体会,分享到课后的评论区,我们一起讨论,共同进步!也推荐你把这一讲分享更多同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/23SparkMLlib:从“房价预测”开始.md b/专栏/零基础入门Spark/23SparkMLlib:从“房价预测”开始.md new file mode 100644 index 0000000..686f41b --- /dev/null +++ b/专栏/零基础入门Spark/23SparkMLlib:从“房价预测”开始.md @@ -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) + + +模型效果评估 + +模型训练好之后,我们需要对模型的效果进行验证、评估,才能判定模型的“好”、“坏”。这就好比,馅饼烤熟之后,我们得亲自尝一尝,才能知道它的味道跟我们期待的口感是否一致。 + +首先,我们先来看看,模型在训练集上的表现怎么样。在线性回归模型的评估中,我们有很多的指标,用来量化模型的预测误差。其中最具代表性的要数RMSE(Root Mean Squared Error),也就是均方根误差。我们可以通过在模型上调用summary函数,来获取模型在训练集上的评估指标,如下所示。 + +val trainingSummary = lrModel.summary +println(s"RMSE: ${trainingSummary.rootMeanSquaredError}") + +/** 结果打印 +RMSE: 45798.86 +*/ + + +在训练集的数据分布中,房价的值域在(34900,755000)之间,因此,45798.86的预测误差还是相当大的。这说明我们得到的模型,甚至没有很好地拟合训练数据。换句话说,训练得到的模型,处在一个“欠拟合”的状态。 + +这其实很好理解,一方面,咱们的模型过于简单,线性回归的拟合能力本身就非常有限。 + +再者,在数据方面,我们目前仅仅使用了4个字段(LotAreaInt,GrLivAreaInt,TotalBsmtSFInt,GarageAreaInt)。房价影响因素众多,仅用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”竞赛项目下载训练数据,完成从数据加载到模型训练的整个过程。 + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友,一起动手试试从数据加载到模型训练的整个过程。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/24特征工程(上):有哪些常用的特征处理函数?.md b/专栏/零基础入门Spark/24特征工程(上):有哪些常用的特征处理函数?.md new file mode 100644 index 0000000..62fac21 --- /dev/null +++ b/专栏/零基础入门Spark/24特征工程(上):有哪些常用的特征处理函数?.md @@ -0,0 +1,369 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 24 特征工程(上):有哪些常用的特征处理函数? + 你好,我是吴磊。 + +在上一讲,我们一起构建了一个简单的线性回归模型,来预测美国爱荷华州的房价。从模型效果来看,模型的预测能力非常差。不过,事出有因,一方面线性回归的拟合能力有限,再者,我们使用的特征也是少的可怜。 + +要想提升模型效果,具体到我们“房价预测”的案例里就是把房价预测得更准,我们需要从特征和模型两个方面着手,逐步对模型进行优化。 + +在机器学习领域,有一条尽人皆知的“潜规则”:Garbage in,garbage out。它的意思是说,当我们喂给模型的数据是“垃圾”的时候,模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话,实际上,它指的是不完善的特征工程。 + +特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。 + +作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。 + +为了减轻你的学习负担,我把特征工程拆成了上、下两篇。我会用两讲的内容,带你了解在Spark MLlib的开发框架下,都有哪些完善特征工程的方法。总的来说,我们需要学习6大类特征处理方法,今天这一讲,我们先来学习前3类,下一讲再学习另外3类。 + +课程安排 + +打开Spark MLlib特征工程页面,你会发现这里罗列着数不清的特征处理函数,让人眼花缭乱。作为初学者,看到这么长的列表,更是会感到无所适从。 + + + +不过,你别担心,对于列表中的函数,结合过往的应用经验,我会从特征工程的视角出发,把它们分门别类地进行归类。 + + + +如图所示,从原始数据生成可用于模型训练的训练样本(这个过程又叫“特征工程”),我们有很长的路要走。通常来说,对于原始数据中的字段,我们会把它们分为数值型(Numeric)和非数值型(Categorical)。之所以要这样区分,原因在于字段类型不同,处理方法也不同。 + +在上图中,从左到右,Spark MLlib特征处理函数可以被分为如下几类,依次是: + + +预处理 +特征选择 +归一化 +离散化 +Embedding +向量计算 + + +除此之外,Spark MLlib还提供了一些用于自然语言处理(NLP,Natural 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为例看一看,对于任意的房屋面积ei,MinMaxScaler使用如下公式来完成对“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函数,完成对目标字段的归一化。 + +这段代码执行完毕之后,engineeringData(DataFrame)就包含了多个后缀为“Scaled”的数据列,这些数据列的内容,就是对应原始字段的归一化数据,如下所示。 + + + +好啦,到此为止,我们以MinMaxScaler为代表,学习了Spark MLlib框架中数据归一化的用法,打通了特征工程的第三关。 + + + +重点回顾 + +好啦,今天的内容讲完啦,我们一起来做个总结。今天这一讲,我们主要围绕特征工程展开,你需要掌握特征工程不同环节的特征处理方法,尤其是那些最具代表性的特征处理函数。 + +从原始数据到生成训练样本,特征工程可以被分为如下几个环节,我们今天重点讲解了其中的前三个环节,也就是预处理、特征选择和归一化。 + + + +针对不同环节,Spark MLlib框架提供了丰富的特征处理函数。作为预处理环节的代表,StringIndexer负责对非数值型特征做初步处理,将模型无法直接消费的字符串转换为数值。 + +特征选择的动机,在于提取与预测标的关联度更高的特征,从而精简模型尺寸、提升模型泛化能力。特征选择可以从两方面入手,业务出发的专家经验和基于数据的统计分析。 + + + +Spark MLlib基于不同的统计方法,提供了多样的特征选择器(Feature Selectors),其中ChiSqSelector以卡方检验为基础,选择相关度最高的前N个特征。 + +归一化的目的,在于去掉不同特征之间量纲的影响,避免量纲不一致而导致的梯度下降震荡、模型收敛效率低下等问题。归一化的具体做法,是把不同特征都缩放到同一个值域。在这方面,Spark MLlib提供了多种归一化方法供开发者选择。 + +在下一讲,我们将继续离散化、Embedding和向量计算这3个环节的学习,最后还会带你整体看一下各环节优化过后的模型效果,敬请期待。 + +每课一练 + +对于我们今天讲解的特征处理函数,如StringIndexer、ChiSqSelector、MinMaxScaler,你能说说它们之间的区别和共同点吗? + +欢迎你在留言区跟我交流互动,也推荐你把今天的内容转发给更多同事和朋友,跟他一起交流特征工程相关的内容。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/25特征工程(下):有哪些常用的特征处理函数?.md b/专栏/零基础入门Spark/25特征工程(下):有哪些常用的特征处理函数?.md new file mode 100644 index 0000000..965e526 --- /dev/null +++ b/专栏/零基础入门Spark/25特征工程(下):有哪些常用的特征处理函数?.md @@ -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 < 3,6 > 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,你能说说他们之间的区别和共同点吗? + +欢迎你在留言区记录你的收获与思考,也欢迎你向更多同事、朋友分享今天的内容,说不定就能帮他解决特征工程方面的问题。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/26模型训练(上):决策树系列算法详解.md b/专栏/零基础入门Spark/26模型训练(上):决策树系列算法详解.md new file mode 100644 index 0000000..f92cebe --- /dev/null +++ b/专栏/零基础入门Spark/26模型训练(上):决策树系列算法详解.md @@ -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个实例又可以分为两类,如下图所示。 + +为了照顾基础薄弱的同学,我们需要先搞清楚决策树、GBDT(Gradient-boosted Decision Trees)和RF(Random 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(用信息熵来量化),也即每个节点的标签都是一样的。但在实际工作中,我们很难做到这一点。不仅如此,一般来说,一棵决策树的拟合能力是相当有限的,它很难把样本的纯度提升得足够高。 + +这时就要说到GBDT(Gradient-boosted Decision Trees)和RF(Random Forest)这两种算法了,尽管它们的设计思想各不相同,但本质上都是为了进一步提升数据样本的纯度。 + +Random Forest + +Random Forest,又叫“随机森林”,它的设计思想是“三个臭皮匠、赛过诸葛亮”。既然一棵树的拟合能力有限,那么就用多棵树来“凑数儿”,毕竟,老话说得好:人多出韩信。 + +举例来说,我们想结合多个特征,来对房屋质量进行分类。对于给定的数据样本,随机森林算法会训练多棵决策树,树与树之间是相互独立的,彼此之间不存在任何依赖关系。对于每一棵树,算法会随机选择部分样本与部分特征,来进行决策树的构建,这也是随机森林命名中“随机”一词的由来。 + + + +以上图为例,随机森林算法构建了3棵决策树,第一棵用到了“居室数量”和“房屋面积”这两个特征,而第二棵选择了“建筑年龄”、“装修情况”和“房屋类型”三个特征,最后一棵树选择的是“是否带泳池”、“房屋面积”、“装修情况”和“厨房数量”四个特征。 + +每棵树都把遍历的样本分为5个类别,每个类别都包含部分样本。当有新的数据样本需要预测房屋质量时,我们把数据样本同时“喂给”随机森林的3棵树,预测结果取决于3棵树各自的输出结果。 + +假设样本经过第一棵树的判别之后,掉落在了Set3;经过第二棵树的“决策”之后,掉落在了Set2;而经过第三棵树的判定之后,归类到了Set3,那么样本最终的预测结果就是Set3。也即按照“少数服从多数”的原则,随机森林最终的预测结果,会取所有决策树结果中的大多数。回归问题也是类似,最简单的办法,就是取所有决策树判定结果的均值。 + +GBDT + +接下来,我们再说说GBDT(Gradient-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模型算法各自的优缺点吗? + +欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/27模型训练(中):回归、分类和聚类算法详解.md b/专栏/零基础入门Spark/27模型训练(中):回归、分类和聚类算法详解.md new file mode 100644 index 0000000..6d7f8af --- /dev/null +++ b/专栏/零基础入门Spark/27模型训练(中):回归、分类和聚类算法详解.md @@ -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分别用于设置模型的超参数,也即最大树深与最大迭代次数(决策树的数量),从而避免模型出现过拟合的情况。 + +每课一练 + +对于房价预测与房屋分类这两个场景,你觉得在它们之间,有代码(尤其是特征工程部分的代码)复用的必要和可能性吗? + +欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/28模型训练(下):协同过滤与频繁项集算法详解.md b/专栏/零基础入门Spark/28模型训练(下):协同过滤与频繁项集算法详解.md new file mode 100644 index 0000000..71e19f4 --- /dev/null +++ b/专栏/零基础入门Spark/28模型训练(下):协同过滤与频繁项集算法详解.md @@ -0,0 +1,260 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 28 模型训练(下):协同过滤与频繁项集算法详解 + 你好,我是吴磊。 + +如果你平时爱刷抖音,或者热衷看电影,不知道有没有过这样的体验:这类影视App你用得越久,它就好像会读心术一样,总能给你推荐对胃口的内容。其实这种迎合用户喜好的推荐,离不开机器学习中的推荐算法。 + +今天是咱们模型训练的最后一讲,在今天这一讲,我们就结合两个有趣的电影推荐场景,为你讲解Spark MLlib支持的协同过滤与频繁项集算法。与上一讲一样,咱们还是先来贴出下面这张“全景图”,方便你对学过和即将要学的知识做到心中有数。 + + + +电影推荐场景 + +今天这一讲,咱们结合Kaggle竞赛中的MovieLens数据集,使用不同算法来构建简易的电影推荐引擎。尽管MovieLens数据集包含了多个文件,但课程中主要用到的,是ratings.csv这个文件。文件中的每条数据条目,记录的都是用户对于电影的打分,如下表所示。 + + + +其中第一列userId为用户ID,movieId表示电影ID,而rating就是用户对于电影的评分。像这样,同时存有用户与物品(电影)信息的二维表,我们把它们统称为“交互矩阵”,或是“共现矩阵”。你可能会疑惑,通过这么一份简单的二维表,我们能干些什么呢? + +可别小瞧这份数据,与合适的模型算法搭配在一起,我就能根据它们构建初具模样的推荐引擎。在Spark MLlib框架下,至少有两种模型算法可以做到这一点,一个是协同过滤(Collaborative Filtering),另一个是频繁项集(Frequency Patterns)。其中,前者天生就是用来做推荐用的,而后者是一种常规的非监督学习算法,你可以结合数据特点,把这个算法灵活运用于推荐场景。 + +协同过滤 + +我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。 + +这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。 + +交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单? + +那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。 + + + +在数学上,给定维度为(M,N)的交互矩阵C,我们可以把它分解为两个矩阵U与I的乘积。其中,我们可以把U称作“用户矩阵”,它的维度为(M,K);而I可以看作是“物品矩阵”,它的维度是(K,N)。 + +在用户矩阵与物品矩阵中,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 + +// 基于ALS(Alternative Least Squares,交替最小二乘)构建模型,完成矩阵分解 +val als = new ALS() +.setUserCol("userIdInt") +.setItemCol("movieIdInt") +.setRatingCol("ratingFloat") +.setMaxIter(20) + +val alsModel = als.fit(trainingData) + + +值得一提的是,在Spark MLlib的框架下,对于协同过滤的实现,Spark并没有采用解析解的方式(数学上严格的矩阵分解),而是用了一种近似的方式来去近似矩阵分解。这种方式,就是ALS(Alternative Least Squares,交替最小二乘)。 + +具体来说,给定交互矩阵C,对于用户矩阵U与物品矩阵I,Spark先给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] + +alsModel.userFactors.show(1) +/** 结果打印 ++---+--------------------+ +| id| features| ++---+--------------------+ +| 10|[0.53652495, -1.0...| ++---+--------------------+ +*/ + +alsModel.itemFactors +// org.apache.spark.sql.DataFrame = [id: int, features: array] + +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] + +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条电影集合数据。对于(“八佰”、“金刚川”、“长津湖”)这个组合来说,当且仅当它出现的次数大于712(7120 * 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的电影推荐给他/她。 + +重点回顾 + +好啦,到此为止,模型训练的上、中、下三讲,我们就全部讲完啦!这三讲的内容较多,涉及的算法也很多,为了让你对他们有一个整体的把握,我把这些算法的分类、原理、特点与适用场景,都整理到了如下的表格中,供你随时回顾。 + + + +不难发现,机器学习的场景众多,不同的场景下,又有多种不同的算法供我们选择。掌握这些算法的原理与特性,有利于我们高效地进行模型选型与模型训练,从而去解决不同场景下的特定问题。 + +对于算法的调优与应用,还需要你结合日常的实践去进一步验证、巩固,也欢迎你在留言区分享你的心得与体会,让我们一起加油! + +每课一练 + +对于本讲介绍的两种推荐思路(协同过滤与频繁项集),你能说说他们各自的优劣势吗? + +你有什么学习收获或者疑问,都可以跟我交流,咱们留言区见。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/29SparkMLlibPipeline:高效开发机器学习应用.md b/专栏/零基础入门Spark/29SparkMLlibPipeline:高效开发机器学习应用.md new file mode 100644 index 0000000..82d1b89 --- /dev/null +++ b/专栏/零基础入门Spark/29SparkMLlibPipeline:高效开发机器学习应用.md @@ -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。相比Transformer,Estimator要简单得多,它实际上就是各类模型算法,如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中的每一个Stage(Transformer或Estimator) +val formerStages = unfitPipeline.getStages + +// 去掉Pipeline中最后一个组件,也即Estimator:GBTRegressor +val formerStagesWithoutModel = formerStages.dropRight(1) + +import org.apache.spark.ml.regression.RandomForestRegressor + +// 定义新的Estimator:RandomForestRegressor +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。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/30StructuredStreaming:从“流动的WordCount”开始.md b/专栏/零基础入门Spark/30StructuredStreaming:从“流动的WordCount”开始.md new file mode 100644 index 0000000..b3feb87 --- /dev/null +++ b/专栏/零基础入门Spark/30StructuredStreaming:从“流动的WordCount”开始.md @@ -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的差异与特点,我们留到[下一讲]再去展开。 + + + +这里我们先来说说Console,Console就是我们常说的终端,选择Console作为Sink,Spark会把结果打印到终端。因此,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,而对于Sink,Spark支持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流计算应用。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/31新一代流处理框架:Batchmode和Continuousmode哪家强?.md b/专栏/零基础入门Spark/31新一代流处理框架:Batchmode和Continuousmode哪家强?.md new file mode 100644 index 0000000..9f8d53a --- /dev/null +++ b/专栏/零基础入门Spark/31新一代流处理框架:Batchmode和Continuousmode哪家强?.md @@ -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社区官方的说法是:“结合幂等的Sink,Structured Streaming能够提供Exactly once的容错能力”。 + +实际上,这句话应该拆解为两部分。在数据处理上,结合容错机制,Structured Streaming本身能够提供“At least once”的处理能力。而结合幂等的Sink,Structured 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 Log(WAL日志)。 + +换句话说,当源数据流进Source之后,它需要先到Checkpoint目录下进行“报道”,然后才会被Structured Streaming引擎处理。毫无疑问,“报道”这一步耽搁了端到端的处理延迟,如下图所示。 + + + +除此之外,由于每个Micro-batch都会触发一个Spark作业,我们知道,作业与任务的频繁调度会引入计算开销,因此也会带来不同程度的延迟。在运行模式与容错机制的双重加持下,Batch mode的延迟水平往往维持在秒这个量级,在最好的情况下能达到几百毫秒左右。 + +Continuous mode容错 + +相比Batch mode,Continuous 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机制需要预写WAL(Write 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中写日志的动作,也挪到数据消费与处理之后呢? + +欢迎你在留言区跟我交流讨论,也推荐你把这一讲的内容分享给更多朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/32Window操作&Watermark:流处理引擎提供了哪些优秀机制?.md b/专栏/零基础入门Spark/32Window操作&Watermark:流处理引擎提供了哪些优秀机制?.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/零基础入门Spark/33流计算中的数据关联:流与流、流与批.md b/专栏/零基础入门Spark/33流计算中的数据关联:流与流、流与批.md new file mode 100644 index 0000000..2e50b01 --- /dev/null +++ b/专栏/零基础入门Spark/33流计算中的数据关联:流与流、流与批.md @@ -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和关联条件,来同时约束状态数据维护的成本与开销。那么,在流批关联中,我们是否也需要同样的约束呢?为什么? + +欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。 + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/34Spark+Kafka:流计算中的“万金油”.md b/专栏/零基础入门Spark/34Spark+Kafka:流计算中的“万金油”.md new file mode 100644 index 0000000..e69de29 diff --git a/专栏/零基础入门Spark/用户故事小王:保持空杯心态,不做井底之蛙.md b/专栏/零基础入门Spark/用户故事小王:保持空杯心态,不做井底之蛙.md new file mode 100644 index 0000000..2e8a035 --- /dev/null +++ b/专栏/零基础入门Spark/用户故事小王:保持空杯心态,不做井底之蛙.md @@ -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是大数据工程师躲不掉的事情,那么咱们就踏踏实实、按部就班地学习、行动起来吧。 + +纸上得来终觉浅,绝知此事要躬行。只有“躬行”了,专栏里的知识才会缓缓流进你的大脑里,当你用双手在键盘辛勤耕耘的时候,再从你飞舞的指尖上流出,编织成优雅美丽的代码。 + +保持空杯心态,不做井底之蛙。希望我们可以一起精进技术,学以致用,加油! + + + + \ No newline at end of file diff --git a/专栏/零基础入门Spark/结束语进入时间裂缝,持续学习.md b/专栏/零基础入门Spark/结束语进入时间裂缝,持续学习.md new file mode 100644 index 0000000..11987c3 --- /dev/null +++ b/专栏/零基础入门Spark/结束语进入时间裂缝,持续学习.md @@ -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”。 + +让我们抓住每一个成长精进的契机,进入时间裂缝,持续学习,与君共勉。 + +最后,我还给你准备了一份毕业问卷,题目不多,两分钟左右就能填好,期待你能畅所欲言,谢谢。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/001「战略篇」访谈DDD和微服务是什么关系?.md b/专栏/领域驱动设计实践(完)/001「战略篇」访谈DDD和微服务是什么关系?.md new file mode 100644 index 0000000..50eb875 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/001「战略篇」访谈DDD和微服务是什么关系?.md @@ -0,0 +1,92 @@ + + + 阿里云2C2G3M 99元/年,老用户 也可以哦 + + + 001 「战略篇」访谈 DDD 和微服务是什么关系? + 相信很多朋友对领域驱动设计会有这样或那样的困惑,比如领域驱动设计是什么?它在工作中有什么作用?为什么国内关于这方面的书籍少之又少?…… 为了解决这些疑惑,有幸邀请到专家张逸老师来聊聊领域驱动设计,下面是 GitChat 独家采访记录。 + + +GitChat:领域驱动设计(Domain Driven Design,DDD)自诞生以来已有十几年时间,这门本已步入老年的方法学却因为微服务的兴起而焕发了第二春。您说过这可能要归功于 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 就成为了我其中的一个技术标签了。这个说法的内在含义,就是要寻找和定位自己的技术发展方向,然后往更深的方向钻研,最终成为这个方向的技术专家。因此,结合自己的能力特长、兴趣点以及技术发展趋势去规划自己的技术发展方向,才是技术人员最应该思考并践行的。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/002「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md b/专栏/领域驱动设计实践(完)/002「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md new file mode 100644 index 0000000..6f5ebc2 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/002「战略篇」开篇词:领域驱动设计,重焕青春的设计经典.md @@ -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 篇末尾添加小编的微信号。 + +阅读文章过程中有任何疑问随时可以跟其他小伙伴讨论,或者直接向作者提问(作者看到后抽空回复)。你的分享不仅帮助他人,更会提升自己。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/003领域驱动设计概览.md b/专栏/领域驱动设计实践(完)/003领域驱动设计概览.md new file mode 100644 index 0000000..a6e5fa9 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/003领域驱动设计概览.md @@ -0,0 +1,79 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 003 领域驱动设计概览 + 领域驱动设计(Domain Driven Design,DDD)是由 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 资源)获取、添加、删除或者修改领域对象。领域模型中的资源库不应该暴露访问领域对象的技术实现细节。 + +演进的领域驱动设计过程 + +战略设计会控制和分解战术设计的边界与粒度,战术设计则以实证角度验证领域模型的有效性、完整性与一致性,进而以演进的方式对之前的战略设计阶段进行迭代,从而形成一种螺旋式上升的迭代设计过程,如下图所示: + + + +面对客户的业务需求,由领域专家与开发团队展开充分的交流,经过需求分析与知识提炼,以获得清晰的问题域。通过对问题域进行分析和建模,识别限界上下文,利用它划分相对独立的领域,再通过上下文映射建立它们之间的关系,辅以分层架构与六边形架构划分系统的逻辑边界与物理边界,界定领域与技术之间的界限。之后,进入战术设计阶段,深入到限界上下文内对领域进行建模,并以领域模型指导程序设计与编码实现。若在实现过程中,发现领域模型存在重复、错位或缺失时,再进而对已有模型进行重构,甚至重新划分限界上下文。 + +两个不同阶段的设计目标是保持一致的,它们是一个连贯的过程,彼此之间又相互指导与规范,并最终保证一个有效的领域模型和一个富有表达力的实现同时演进。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/004深入分析软件的复杂度.md b/专栏/领域驱动设计实践(完)/004深入分析软件的复杂度.md new file mode 100644 index 0000000..582d856 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/004深入分析软件的复杂度.md @@ -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 的升级,感谢这些框架或平台设计人员对兼容性的体贴照顾,使得我们的升级成本能够被降到最低;但是在升级之后,倘若没有对系统做全方位的回归测试,我们的内心始终是惴惴不安的。 + +对第三方的依赖看似简单,殊不知我们所依赖的库、平台或者框架又可能依赖了若干对于它们而言又份属第三方的更多库、平台和框架。每回初次构建软件系统时,我都为漫长等待的依赖下载过程而感觉烦躁不安。多种版本共存时可能带来的所谓依赖地狱,只要亲身经历过,就没有不感到不寒而栗的。倘若你运气欠佳,可能还会有各种古怪问题接踵而来,让你应接不暇、疲于奔命。 + +如果变化是不可预测的,那么软件系统也会变得不可预测。一方面我们要尽可能地控制变化,至少要将变化产生的影响限制在较小的空间范围内;另一方面又要保证系统不会因为满足可扩展性而变得更加复杂,最后背上过度设计的坏名声。软件设计者们就像走在高空钢缆的技巧挑战者,惊险地调整重心以维持行动的平衡。故而,变化之难,在于如何平衡。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/005控制软件复杂度的原则.md b/专栏/领域驱动设计实践(完)/005控制软件复杂度的原则.md new file mode 100644 index 0000000..853b37f --- /dev/null +++ b/专栏/领域驱动设计实践(完)/005控制软件复杂度的原则.md @@ -0,0 +1,54 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 005 控制软件复杂度的原则 + 虽然说认识到软件系统的复杂本性,并不足以让我们应对其复杂,并寻找到简化系统的解决之道;然而,如果我们连导致软件复杂度的本源都茫然不知,又怎么谈得上控制复杂呢?既然我们认为导致软件系统变得复杂的成因是规模、结构与变化三要素,则控制复杂度的原则就需要对它们进行各个击破。 + +分而治之、控制规模 + +针对规模带来的复杂度,我们应注意克制做大、做全的贪婪野心,尽力保证系统的小规模。简单说来,就是分而治之的思想,遵循小即是美的设计美学。 + +丹尼斯·里奇(Dennis MacAlistair Ritchie)从大型项目 Multics 的失败中总结出 KISS(Keep 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 Architecture),Jeffrey Palermo 提出的洋葱架构(Onion Architecture),James Coplien 与 Trygve Reenskaug 提出的 DCI 架构,Ivar Jacobson 提出的 BCE 设计方法。结果,他认为这些方法的共同特征都遵循了“关注点分离”架构原则,由此提出了整洁架构的思想。 + +整洁架构提出了一个可测试的模型,无需依赖于任何基础设施就可以对它进行测试,只需通过边界对象发送和接收对应的数据结构即可。它们都遵循稳定依赖原则,不对变化或易于变化的事物形成依赖。整洁架构模型让外部易变的部分依赖于更加稳定的领域模型,从而保证了核心的领域模型不会受到外部的影响。典型的整洁架构如下图所示: + + + +整洁架构的目的在于识别整个架构不同视角以及不同抽象层次的关注点,并为这些关注点划分不同层次的边界,从而使得整个架构变得更为清晰,以减少不必要的耦合。要做到这一点,则需要合理地进行职责分配,良好的封装与抽象,并在约束的指导下为架构建立一致的风格,这是许多良好系统的设计特征。 + +拥抱变化 + +变化对软件系统带来的影响可以说是无解,然而我们不能因此而消极颓废,套用 Kent Beck 的话来说,我们必须“拥抱变化”。除了在开发过程中,我们应尽可能做到敏捷与快速迭代,以此来抵消变化带来的影响;在架构设计层面,我们还可以分析哪些架构质量属性与变化有关,这些质量属性包括: + + +可进化性(Evolvability) +可扩展性(Extensibility) +可定制性(Customizability) + + +要保证系统的可进化性,可以划分设计单元的边界,以确定每个设计单元应该履行的职责以及需要与其他设计单元协作的接口。这些设计单元具有不同的设计粒度,包括函数、对象、模块、组件及服务。由于每个设计单元都有自己的边界,边界内的实现细节不会影响到外部的其他设计单元,我们就可以非常容易地替换单元内部的实现细节,保证了它们的可进化性。 + +要满足系统的可扩展性,首先要学会识别软件系统中的变化点(热点),常见的变化点包括业务规则、算法策略、外部服务、硬件支持、命令请求、协议标准、数据格式、业务流程、系统配置、界面表现等。处理这些变化点的核心就是“封装”,通过隐藏细节、引入间接等方式来隔离变化、降低耦合。一些常见的架构风格,如基于事件的集成、管道—过滤器等的引入,都可以在一定程度上提高系统可扩展性。 + +可定制性意味着可以提供特别的功能与服务。Fielding 在《架构风格与基于网络的软件架构设计》提到:“支持可定制性的风格也可能会提高简单性和可扩展性”。在 SaaS 风格的系统架构中,我们常常通过引入元数据(Metadata)来支持系统的可定制。插件模式也是满足可定制性的常见做法,它通过提供统一的插件接口,使得用户可以在系统之外按照指定接口编写插件来扩展定制化的功能。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/006领域驱动设计对软件复杂度的应对(上).md b/专栏/领域驱动设计实践(完)/006领域驱动设计对软件复杂度的应对(上).md new file mode 100644 index 0000000..ec2eb74 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/006领域驱动设计对软件复杂度的应对(上).md @@ -0,0 +1,153 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 006 领域驱动设计对软件复杂度的应对(上) + 不管是因为规模与结构制造的理解力障碍,还是因为变化带来的预测能力问题,最终的决定因素还是因为需求。Eric Evans 认为“很多应用程序最主要的复杂性并不在技术上,而是来自领域本身、用户的活动或业务”。因而,领域驱动设计关注的焦点在于领域和领域逻辑,因为软件系统的本质其实是给客户(用户)提供具有业务价值的领域功能。 + +需求引起的软件复杂度 + +需求分为业务需求与质量属性需求,因而需求引起的复杂度可以分为两个方面:技术复杂度与业务复杂度。 + +技术复杂度来自需求的质量属性,诸如安全、高性能、高并发、高可用性等需求,为软件设计带来了极大的挑战,让人痛苦的是这些因素彼此之间可能又互相矛盾、互相影响。例如,系统安全性要求对访问进行控制,无论是增加防火墙,还是对传递的消息进行加密,又或者对访问请求进行认证和授权等,都需要为整个系统架构添加额外的间接层,这不可避免会对访问的低延迟产生影响,拖慢了系统的整体性能。又例如,为了满足系统的高并发访问,我们需要对应用服务进行物理分解,通过横向增加更多的机器来分散访问负载;同时,还可以将一个同步的访问请求拆分为多级步骤的异步请求,再通过引入消息中间件对这些请求进行整合和分散处理。这种分离一方面增加了系统架构的复杂性,另一方面也因为引入了更多的资源,使得系统的高可用面临挑战,并增加了维护数据一致性的难度。 + +业务复杂度对应了客户的业务需求,因而这种复杂度往往会随着需求规模的增大而增加。由于需求不可能做到完全独立,一旦规模扩大到一定程度,不仅产生了功能数量的增加,还会因为功能互相之间的依赖与影响使得这种复杂度产生叠加,进而影响到整个系统的质量属性,比如系统的可维护性与可扩展性。在考虑系统的业务需求时,还会因为沟通不畅、客户需求不清晰等多种局外因素而带来的需求变更和修改。如果不能很好地控制这种变更,则可能会因为多次修改而导致业务逻辑纠缠不清,系统可能开始慢慢腐烂而变得不可维护,最终形成一种如 Brian Foote 和 Joseph Yoder 所说的“大泥球”系统。 + +以电商系统的促销规则为例。针对不同类型的顾客与产品,商家会提供不同的促销力度;促销的形式多种多样,包括赠送积分、红包、优惠券、礼品;促销的周期需要支持定制,既可以是特定的日期,如双十一促销,也可以是节假日的固定促销模式。如果我们在设计时没有充分考虑促销规则的复杂度,并处理好促销规则与商品、顾客、卖家与支付乃至于物流、仓储之间的关系,开发过程则会变得踉踉跄跄、举步维艰。 + +技术复杂度与业务复杂度并非完全独立,二者混合在一起产生的化合作用更让系统的复杂度变得不可预期,难以掌控。同时,技术的变化维度与业务的变化维度并不相同,产生变化的原因也不一致,倘若未能很好地界定二者之间的关系,系统架构缺乏清晰边界,会变得难以梳理。复杂度一旦增加,团队规模也将随之扩大,再揉以严峻的交付周期、人员流动等诸多因素,就好似将各种不稳定的易燃易爆气体混合在一个不可逃逸的密闭容器中一般,随时都可能爆炸: + + + +随着业务需求的增加与变化,以及对质量属性的高标准要求,自然也引起了软件系统规模的增大与结构的繁杂,至于变化,则是软件开发绕不开的话题。因此,当我们面对一个相对复杂的软件系统时,通常面临的问题在于: + + +问题域过于庞大而复杂,使得从问题域中寻求解决方案的挑战增加,该问题与软件系统的规模有关。 +开发人员将业务逻辑的复杂度与技术实现的复杂度混淆在一起,该问题与软件系统的结构有关。 +随着需求的增长和变化,无法控制业务复杂度和技术复杂度,该问题与软件系统的变化有关。 + + +针对这三个问题,领域驱动设计都给出了自己的应对措施。 + +领域驱动设计的应对措施 + +隔离业务复杂度与技术复杂度 + +要避免业务逻辑的复杂度与技术实现的复杂度混淆在一起,首要任务就是确定业务逻辑与技术实现的边界,从而隔离各自的复杂度。这种隔离也是题中应有之义,毕竟技术与业务的关注点完全不同。例如,在电商的领域逻辑中,订单业务关注的业务规则包括验证订单有效性、计算订单总额、提交和审核订单的流程等;技术关注点则从实现层面保障这些业务能够正确地完成,包括确保分布式系统之间的数据一致性,确保服务之间通信的正确性等。 + +业务逻辑并不关心技术是如何实现的,无论采用何种技术,只要业务需求不变,业务规则就不会发生变化。换言之,在理想状态下,我们应该保证业务规则与技术实现是正交的。 + +领域驱动设计通过分层架构与六边形架构来确保业务逻辑与技术实现的隔离。 + +分层架构的关注点分离 + +分层架构遵循了“关注点分离”原则,将属于业务逻辑的关注点放到领域层(Domain Layer)中,而将支撑业务逻辑的技术实现放到基础设施层(Infrastructure Layer)中。同时,领域驱动设计又颇具创见的引入了应用层(Application Layer),应用层扮演了双重角色。一方面它作为业务逻辑的外观(Facade),暴露了能够体现业务用例的应用服务接口;另一方面它又是业务逻辑与技术实现的粘合剂,实现二者之间的协作。 + +下图展现的就是一个典型的领域驱动设计分层架构,蓝色区域的内容与业务逻辑有关,灰色区域的内容与技术实现有关,二者泾渭分明,然后汇合在应用层。应用层确定了业务逻辑与技术实现的边界,通过直接依赖或者依赖注入(DI,Dependency 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 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 forBuyerId(Identity buyerId); + void add(Order order); +} + +public class PlaceOrderService { + @Repository + private OrderRepository orderRepository; + + @Service + private OrderValidator orderValidator; + + public void execute(Identity buyerId, List 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> cacheClient; + + public List findBy(Identity buyerId) { + Optional> cachedOrders = cacheClient.get(buyerId.value()); + if (cachedOrders.isPresent()) { + return orders.get(); + } + List orders = orderRepository.forBuyerId(buyerId); + if (!orders.isEmpty()) { + cacheClient.put(buyerId.value(), orders); + } + return orders; + } +} + +package practiceddd.ecommerce.ordercontext.application.cache; + +public interface CacheClient { + Optional get(String key); + void put(String key, T value); +} + +package practiceddd.ecommerce.ordercontext.infrastructure.cache; + +public class RedisCacheClient implements CacheClient {} + + + +本例中对应的代码结构在分层架构中的体现将会在后续章节中深入介绍,敬请期待~ + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/007领域驱动设计对软件复杂度的应对(下).md b/专栏/领域驱动设计实践(完)/007领域驱动设计对软件复杂度的应对(下).md new file mode 100644 index 0000000..aadc98a --- /dev/null +++ b/专栏/领域驱动设计实践(完)/007领域驱动设计对软件复杂度的应对(下).md @@ -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课中遇到的第三个问题——控制业务复杂度的解答。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/008软件开发团队的沟通与协作.md b/专栏/领域驱动设计实践(完)/008软件开发团队的沟通与协作.md new file mode 100644 index 0000000..6f62c4c --- /dev/null +++ b/专栏/领域驱动设计实践(完)/008软件开发团队的沟通与协作.md @@ -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 的成本。当然,测试的过程同样是沟通与交流的过程,是最有效的需求验证和质量保障的手段。 + +敏捷思想强调个体和团队的协作与沟通,强调快速反馈与及时响应。前面探讨的这些敏捷实践都是行之有效的沟通机制和交流手段,可以帮助团队对需求的理解更加全面、更加准确。只有频繁的沟通,才能就业务需求达成整个团队的共识;只有良好的协作,才能有助于大家一起提炼领域知识,建立统一语言;只有快速反馈,才能尽可能保证领域模型与程序实现的一致。这些都是实践领域驱动设计的基本前提。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/009运用领域场景分析提炼领域知识(上).md b/专栏/领域驱动设计实践(完)/009运用领域场景分析提炼领域知识(上).md new file mode 100644 index 0000000..efb5bf6 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/009运用领域场景分析提炼领域知识(上).md @@ -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)的体现。 + +用例图是领域专家与开发团队可以进行沟通的一种可视化手段,简单形象,还可以避免从一开始就陷入到技术细节中——用例的关注点就是领域。 + +绘制用例图时,切忌闭门造车,最好让团队一起协作。用例表达的领域概念必须精准!在为每个用例进行命名时,我们都应该采纳统一语言中的概念,然后以言简意赅的动宾短语描述用例,并提供英文表达。很多时候,在团队内部已经形成了中文概念的固有印象,一旦翻译成英文,就可能呈现百花齐放的面貌,这就破坏了“统一语言”。为保证用例描述的精准性,可以考虑引入“局外人”对用例提问,局外人不了解业务,任何领域概念对他而言可能都是陌生的。通过不断对用例表达的概念进行提问,团队成员就会在不断的阐释中形成更加清晰的术语定义,对领域行为的认识也会更加精确。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/010运用领域场景分析提炼领域知识(下).md b/专栏/领域驱动设计实践(完)/010运用领域场景分析提炼领域知识(下).md new file mode 100644 index 0000000..bbeb8d4 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/010运用领域场景分析提炼领域知识(下).md @@ -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 Development,BDD)的实践,即强调使用 DSL(Domain 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 模型。当然,这里展现的仅仅是这些方法的冰山一角,讲解的侧重点还是在于通过这些方法来帮助我们提炼领域知识。同时,借助类似用例、用户故事、任务等载体,可以更加有效而直观地帮助我们理解问题域,抽象领域模型,从而为我们建立统一语言奠定共识基础。 + +提炼领域知识 + +提炼领域知识需要贯穿整个领域驱动设计全过程,无论何时,都必须重视领域知识,并时刻维护统一语言。在进行领域场景分析时,这是一个双向的过程。一方面,我们已提炼出来的领域知识会指导我们识别用例,编写用户故事以及测试用例;另一方面,具体的领域场景分析方法又可以进一步帮助我们确认领域知识,并将在团队内达成共识的统一语言更新到之前识别的领域知识中。 + +这种双向的指导与更新非常重要,因为我们提炼的领域知识以及统一语言是领域模型的重要源头。“问渠那得清如许,为有源头活水来。”,只有源头保证了常新,领域模型才能保证健康,才能更好地指导领域驱动设计。 + +通过前面对用例、用户故事与测试驱动开发的介绍,我们发现这三个方法虽然都是领域场景分析的具体实现,但它们在运用层次上各有其优势。用例尤其是用例图的抽象能力更强,更擅长于对系统整体需求进行场景分析;用户故事提供了场景分析的固定模式,善于表达具体场景的业务细节;测试驱动开发则强调对业务的分解,利用编写测试用例的形式驱动领域建模,即使不采用测试先行,让开发者转换为调用者角度去思考领域对象及行为,也是一种很好的建模思想与方法。 + +在提炼领域知识的过程中,我们可以将这三种领域场景分析方法结合起来运用,在不同层次的领域场景中选择不同的场景分析方法,才不至于好高骛远,缺乏对细节的把控,也不至于一叶障目,只见树木不见森林。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/011建立统一语言.md b/专栏/领域驱动设计实践(完)/011建立统一语言.md new file mode 100644 index 0000000..2dfc946 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/011建立统一语言.md @@ -0,0 +1,148 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 011 建立统一语言 + 统一语言是提炼领域知识的产出物,获得统一语言就是需求分析的过程,也是团队中各个角色就系统目标、范围与具体功能达成一致的过程。 + +使用统一语言可以帮助我们将参与讨论的客户、领域专家与开发团队拉到同一个维度空间进行讨论,若没有达成这种一致性,那就是鸡同鸭讲,毫无沟通效率,相反还可能造成误解。因此,在沟通需求时,团队中的每个人都应使用统一语言进行交流。 + +一旦确定了统一语言,无论是与领域专家的讨论,还是最终的实现代码,都可以通过使用相同的术语,清晰准确地定义领域知识。重要的是,当我们建立了符合整个团队皆认同的一套统一语言后,就可以在此基础上寻找正确的领域概念,为建立领域模型提供重要参考。 + +统一语言体现在两个方面: + + +统一的领域术语 +领域行为描述 + + +统一的领域术语 + +形成统一的领域术语,尤其是基于模型的语言概念,是沟通能够达成一致的前提。尤其是开发人员与领域专家之间,他们掌握的知识存在巨大的差异。善于技术的开发人员关注于数据库、通信机制、集成方式与架构体系,而精通业务的领域专家对这些却一窍不通,但他们在讲解业务知识时,使用各种概念如呼吸一般自然,这些对于开发人员来说,却成了天书,这种交流就好似使用两种不同语言的外国人在交谈。记得有一次我去洛杉矶出差,居住期间,需要到一家洗衣店干洗衣服,交付完衣服后,我想向洗衣店老板索要收据,以作为之后领取衣服的凭证。好不容易在我脑中贫瘠的英文词典里搜索到 receipt 这个词语,自以为正确,谁知道讲出来后老板一脸茫然,不是 receipt,那么是 ……invoice?手舞足蹈说了半天,老板才反应过来,递过来一张收据,嘴里吐出 ticket 这个词语,My God,受了中学英语的流毒,我还以为 ticket 这个词语只能用到电影院呢。 + +显然,从需求中提炼出统一语言,其实就是在两个不同的语言世界中进行正确翻译的过程。 + +某些领域术语是有行业规范的,例如财会领域就有标准的会计准则,对于账目、对账、成本、利润等概念都有标准的定义,在一定程度上避免了分歧。然而,标准并非绝对的,在某些行业甚至存在多种标准共存的现象。以民航业的运输统计指标为例,牵涉到与运量、运力以及周转量相关的术语,就存在 ICAO(International Civil Aviation Organization,国际民用航空组织)与IATA(International Air Transport Association,国际航空运输协会)两大体系,而中国民航局又有自己的中文解释,航空公司和各大机场亦有自己衍生的定义。 + +例如,针对一次航空运输的运量,就要分为城市对与航段的运量统计。城市对运量统计的是出发城市到目的城市两点之间的旅客数量,机场将其称之为流向。ICAO 定义的领域术语为 City-pair(OFOD),而 IATA 则命名为 O & D。航段运量又称为载客量,指某个特定航段上所承载的旅客总数量,ICAO将其定义为 TFS(Traffic 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 与 Metric,Category 与 Measure,这种混乱让整个团队的开发成员痛苦不堪,带来了沟通和交流的障碍。就我而言,我宁愿代码命名没有正确地表达领域概念,也不希望出现命名上的不一致性。倘若在建模之初就明确母语和英语的术语表达,就可以做到正本清源! + +领域行为描述 + +从某种程度讲,领域行为描述可以视为领域术语甄别的一种延伸。领域行为是对业务过程的描述,相对于领域术语而言,它体现了更加完整的业务需求以及复杂的业务规则。在描述领域行为时,需要满足以下要求: + + +从领域的角度而非实现角度描述领域行为 +若涉及到领域术语,必须遵循术语表的规范 +强调动词的精确性,符合业务动作在该领域的合理性 +要突出与领域行为有关的领域概念 + + +例如,在项目管理系统中,倘若我们采用 Scrum 的敏捷项目管理流程,要描述 Sprint Backlog 的任务安排,则编写的用户故事如下所示: + +作为一名Scrum Master, +我希望将Sprint Backlog分配给团队成员, +以便于明确Backlog的负责人并跟踪进度。 + +验收标准: +* 被分配的Sprint Backlog没有被关闭 +* 分配成功后,系统会发送邮件给指定的团队成员 +* 一个Sprint Backlog只能分配给一个团队成员 +* 若已有负责人与新的负责人为同一个人,则取消本次分配 +* 每次对Sprint Backlog的分配都需要保存以便于查询 + + + +用户故事中的分配(assign)Sprint 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 { + 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 实例的创建。针对这些疑问和解决方案在后续内容都有详细介绍。 + +定义和确定统一语言,将有利于消除领域专家与团队、以及团队成员之间沟通的分歧与误解,使得各种角色能够在相同的语境下行事,避免盲人摸象的“视觉”障碍。领域的统一语言还是领域建模的重要输入与基础,无论是采用“名词动词法”进行领域建模,还是“四色建模法”或“职责驱动建模”,统一语言都是确定模型的重要参考。如果在确定统一语言的同时,针对领域概念与领域行为皆以英文来表达,就直接为编码实现提供了类、方法、属性等命名的依据,保证代码自身就能直观表达领域含义,提高代码可读性。 + +磨刀不误砍柴工,多花一些时间去打磨统一语言,并非时间的浪费,相反还能改进领域模型乃至编码实现的质量,反过来,领域模型与实现的代码又能避免统一语言的“腐化”,保持语言的常新。重视统一语言,就能促成彼此正面影响的良性循环;否则领域模型与代码会因为沟通不明而泥足深陷,就真是得不偿失了。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/012理解限界上下文.md b/专栏/领域驱动设计实践(完)/012理解限界上下文.md new file mode 100644 index 0000000..f86b153 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/012理解限界上下文.md @@ -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),即对修改是封闭的,对扩展是开放的,该原则其实体现了一个单元的封闭空间与开放空间。封闭空间体现为对细节的封装与隐藏,开放空间体现为对共性特征的抽象与统一,二者共同确保了整个空间的稳定。 + +独立进化与稳定空间刚好相反,指的是减少限界上下文的变化对外界的影响。如果借用限界上下文的上下游关系来阐释,则稳定空间寓意下游限界上下文,无论上游怎么变,我自岿然不动;独立进化寓意上游限界上下文,无论下游有多少,我凌寒独自开。实现上看,要做到独立进化,就必须保证对外公开接口的稳定性,因为这些接口往往被众多消费者使用,一旦修改,就会牵一发而动全身。一个独立进化的限界上下文,需要接口设计良好,符合标准规范,并在版本上考虑了兼容与演化。 + +自治的这四个要素是相辅相成的。最小完备意味着职责是完备的,从而减少了变化的可能;自我履行意味着自治单元能够智能地判断行为是否应该由其履行,当变化发生时,也能聪明审慎地做出合理判断;稳定空间通过隐藏细节和开放抽象接口来封装变化;独立进化则通过约束接口的规范与版本保证内部实现的演化乃至于对实现进行全面地替换。最小完备是基础,只有赋予了限界上下文足够的信息,才能保证它的自我履行。稳定空间与独立进化则一个对内一个对外,是对变化的有效应对,而它们又是通过最小完备和自我履行来保障限界上下文受到变化的影响最小。 + +这四个要素又是高内聚低耦合思想的体现。我们需要根据业务关注点和技术关注点,尽可能将强相关性的内容放到同一个限界上下文中,同时降低限界上下文之间的耦合。对于整个系统架构而言,不同的限界上下文可以采用不同的架构风格与技术决策,而在每个限界上下文内部保持自己的技术独立性与一致性。由于限界上下文边界对技术实现的隔离,不同限界上下文内部实现的多样性并不会影响整体架构的一致性。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/013限界上下文的控制力(上).md b/专栏/领域驱动设计实践(完)/013限界上下文的控制力(上).md new file mode 100644 index 0000000..32433e8 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/013限界上下文的控制力(上).md @@ -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(n−1)2 + +联结的数量直接决定了沟通的成本,以 6 人团队来计算,联结的数量为 15。如果在原有六人团队的规模上翻倍,则联结数陡增至 66。对于传统项目管理而言,一个 50 人的团队其实是一个小型团队,根据该公式计算得出的联结数竟然达到了惊人的 1225。如下图所示,我们可以看到随着团队规模的扩大,联结数的增长以远超线性增长的速度发展,因而沟通的成本也将随之发生颠覆性的改变: + + + +随着沟通成本的增加,团队的适应性也会下降。Jim Highsmith 在 Adaptive Software Development 一书中写道: + + +最佳的单节点(你可以想象成是通信网络中可以唯一定位的人或群体)联结数是一个比较小的值,它不太容易受网络规模的影响。即使网络变大,节点数量增加,每个节点所拥有的联结数量也一定保持着相对稳定的状态。 + + +要做到人数增加不影响到联结数,就是要找到这个节点网络中的最佳沟通数量,也即前面提到的 2PTs 原则。然而团队规模并非解决问题的唯一办法,如果在划分团队权责时出现问题,则团队成员的数量不过是一种组织行为的表象罢了。如果结合领域驱动设计的需求,则我们应该考虑在保持团队规模足够小的前提下,按照软件的特性(Feature)而非组件(Component)来组织软件开发团队,这就是所谓“特性团队”与“组件团队”之分。 + +传统的“组件团队”强调的是专业技能与功能重用,例如,熟练掌握数据库开发技能的成员组建一个数据库团队,深谙前端框架的成员组建一个前端开发团队。这种遵循“专业的事情交给专业的人去做”原则的团队组建模式,可以更好地发挥每个人的技能特长,然而牺牲的却是团队成员业务知识的缺失,客户价值的漠视。这种团队组建模式也加大了团队之间的沟通成本,导致系统的整体功能无法持续和频繁的集成。例如,由于业务变更需要针对该业务特性修改用户描述的一个字段,就需要从数据存储开始考虑到业务模块、服务功能,最后到前端设计。一个小小的修改就需要横跨多个组件团队,这种交流的浪费是多么不必要啊。在交流过程中,倘若还出现了知识流失,或者沟通不到位导致修改没有实现同步,就会带来潜在的缺陷。这种缺陷非常难以发现,即使在高覆盖率的集成测试下暴露了,缺陷定位、问题修复又是一大堆破事儿,需要协调多个团队。邮件沟通、电话沟通、你来我往、扯皮推诿,几天的时光如白驹过隙、转眼就过,问题还未必得到最终的解决。倘若这样的组件团队还是不同供应商的外包团队,分处于不同城市,可以想象这样的场景是多么“美好”!很“幸运”,我在参与某汽车制造商的零售商管理系统时,作为 CRM 模块的负责人,就摊上了这样的破事儿,如今思之,仍然不寒而栗啊! + +为了规避这些问题,组建特性团队更有必要。所谓“特性团队”,就是一个端对端的开发垂直细分领域的跨职能团队,它将需求分析、架构设计、开发测试等多个角色糅合在一起,专注于领域逻辑,实现该领域特性的完整的端对端开发。一个典型的由多个特性团队组成的大型开发团队如下图所示: + + + +如上图所示,我们按照领域特性来组建团队,使得团队成员之间的沟通更加顺畅,至少针对一个领域而言,知识在整个特性团队都是共享的。当然,我们在上图中也看到了组件团队的存在。这是因为在许多复杂软件系统中,毕竟存在一些具有相当门槛的专有功能,需要具有有专门知识或能够应对技术复杂度的团队成员去解决那些公共型的基础型的问题。二者的结合可以取长补短,但应以组建特性团队为主。 + +特性团队专注的领域特性,是与领域驱动设计中限界上下文对应的领域是相对应的。当我们确定了限界上下文时,其实也就等同于确定了特性团队的工作边界,确定了限界上下文之间的关系,也就意味着确定了特性团队之间的合作模式;反之亦然。之所以如此,则是因为康威定律(Conway’s Law)为我们提供了理论支持。 + +康威定律认为:“任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。” 在康威定律中起到关键杠杆作用的是沟通成本。如果同一个限界上下文的工作交给了两个不同的团队分工完成,为了合力解决问题,就必然需要这两个团队进行密切的沟通。然而,团队间的沟通成本显然要高于团队内的沟通成本,为了降低日趋增高的成本,就需要重新划分团队。反过来,如果让同一个团队分头做两个限界上下文的工作,则会因为工作的弱相关性带来自然而然的团队隔离。 + + + +如上图所示,我们可以设想这样一种场景,如果有两个限界上下文的工作,分配给两个不同的团队。分配工作时,却没有按照限界上下文的边界去组建团队,即每个团队会同时承担两个限界上下文的工作。试想,这会造成多少不必要的沟通成本浪费?借用 ORM(Object Relational Mapping,对象关系映射)的概念,我将这种职责分配的错位称之为“限界上下文与团队的阻抗不匹配”。如果能够将团队与限界上下文重合,就能够降低沟通成本,打造高效的领域特性团队,专注于属于自己的限界上下文开发。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/014限界上下文的控制力(下).md b/专栏/领域驱动设计实践(完)/014限界上下文的控制力(下).md new file mode 100644 index 0000000..4246b0b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/014限界上下文的控制力(下).md @@ -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 并行执行即可满足目标。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/015识别限界上下文(上).md b/专栏/领域驱动设计实践(完)/015识别限界上下文(上).md new file mode 100644 index 0000000..2df85a6 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/015识别限界上下文(上).md @@ -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)”等过于抽象的词语。抽象的词语容易让我们忽视隐藏的领域语言,缺少对领域的精确表达。例如,在文学阅读产品中,我们不能宽泛地写出“管理作品”、“管理作者”、“维护支付信息”等业务活动,而应该挖掘业务含义,只有如此才能得到诸如收藏作品、撰写作品、发布作品、设置作品收费模式、查询支付流水、对账等符合领域知识的描述。当然,这里也有一个业务活动层次的问题。在进行业务分析时,若我们发现只能使用“管理”或“维护”之类的抽象字眼来表述该用户活动时,则说明我们选定的用户活动层次过高,应该继续细化。细化后的业务活动既能更好地表达领域知识,又能让我们更好地按照语义相关性去寻找业务的边界,可谓一举两得。 + +在进行语义相关性判断时,还需要注意业务活动之间可能存在不同的语义相关性。例如,在文学阅读产品中,查询作品、阅读作品与撰写作品具有“作品”的语义相关,而评价作品与评价作者又具有“评价”的语义相关,究竟应该以哪个语义为准呢?没有标准!我们只能按照相关性的耦合程度进行判断。如果我们将评价视为一个相对独立的限界上下文,则评价作品与评价作者放入评价上下文会更好。 + +功能相关性 + +从功能角度去分析业务活动是否彼此关联和依赖,倘若存在关联和依赖,可以作为归类的特征,这种关联性,代表了功能之间的相关性。倘若两个功能必须同时存在,又或者缺少一个功能,另一个功能是不完整的,则二者就是功能强相关的。通常,这种功能相关性极具有欺骗性,因为系统总是包含这样那样彼此依赖的功能。要判断这种依赖关系的强弱,并不比分析人与人之间的关系简单。倘若我们运用用例分析方法,就可以通过用例之间的关系来判别功能相关性,如用例的包含与扩展关系,其中包含关系展现了功能的强相关性。所谓“功能相关性”,指的就是职责的内聚性,强相关就等于高内聚。故而从这个角度看,功能相关性的判断标准恰好符合“高内聚、松耦合”的设计原则。 + +仍然以前面提到的文学阅读产品为例。发布作品与验证作品内容是功能相关的,且属于用例的包含关系,因为如果没有对发布的作品内容进行验证,就不允许发布作品。对于这种强相关的功能,我们通常都会考虑将其归入到同一个限界上下文。又例如发布作品与设置作品收费模式是功能相关的,但并非强相关,因为设置作品收费模式并非发布作品的前置约束条件,属于用例中的扩展关系。但由于二者还存在语义相关性,因而将其放入到同一个限界上下文中也是合理的。 + +两个相关的功能未必一定属于同一个限界上下文。例如,购买作品与支付购买费用是功能相关的,且前者依赖于后者,但后者从领域知识的角度判断,却应该分配给支付上下文,我们非但不能将其紧耦合在一起,还应该竭尽所能降低二者之间的耦合度。因此,我在识别限界上下文时,仅仅将“功能相关性”作为一种可行的参考,它并不可靠,却能给你一些提醒。事实上,功能相关性往往会与上下文之间的协作关系有关。由于这种功能相关性恰恰对应了用例之间的包含与扩展关系,它们往往又可成为识别限界上下文边界的关键点。我在后面讲解上下文映射时还会详细阐释。 + +为业务边界命名 + +无论是语义相关性还是功能相关性,都是分类业务活动的一种判断标准。一旦我们将识别出来的业务活动进行归类,就自然而然地为它们划定了业务边界,接下来,我们需要对划定的业务边界进行命名,这个命名的过程其实就是识别所有业务活动共同特征,并以最准确地名词来表达该特征。倘若我们划分的业务活动欠妥当,对这个业务边界命名就会成为一种巨大的挑战。例如,我们从建立读者群、加入读者群,发布群内消息、实时聊天、发送离线消息、一对一私聊与发送私信等业务活动找到“社交”的共同特征,因而得到社交上下文。但如果我们将阅读作品、收藏作品与关注作者、查看作者信息放在一个业务边界内,命名就变得有些棘手了,我们总不可能称呼其为“作品与作者”上下文吧!因此,对业务边界的命名可以算作是对限界上下文识别的一种检验手段。 + +整体而言,从业务边界识别上下文的重点在于“领域”。若理解领域逻辑有误,就可能影响限界上下文的识别。因此,这个阶段需要开发团队与领域专家紧密合作,这个阶段也将是一个充分讨论和分析的过程。它是一个迭代的过程。很多时候,如果我们没有真正去实现这些限界上下文,我们有可能没有完全正确地理解它。当我们距离真正理解业务还有距离的时候,不妨先“草率”地规划它,待到一切都明朗起来,再寻机重构。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/016识别限界上下文(下).md b/专栏/领域驱动设计实践(完)/016识别限界上下文(下).md new file mode 100644 index 0000000..473a8a4 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/016识别限界上下文(下).md @@ -0,0 +1,107 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 016 识别限界上下文(下) + 从工作边界识别限界上下文 + +正如架构设计需要多个视图来全方位体现架构的诸多要素,我们也应借助更多的角度全方位分析限界上下文。如果说为限界上下文划分业务边界,更多的是从业务相关性(内聚)判断业务的归属,那么基于团队合作划分工作边界可以帮助我们确定限界上下文合理的工作粒度。 + +倘若我们认可第 3-2 课中提及的三个原则或实践:2PTs 规则、特性团队、康威定律,则意味着项目经理需要将一个限界上下文要做的工作分配给大约 7~10 人的特性团队。如此看来,对限界上下文的粒度识别就变成了对工作量的估算。我们并没有严谨的算法去准确估算工作量,可是对于一个有经验的项目经理(或者技术负责人),要进行工作量的大致估算,还是能够办到的。当我们发现一个限界上下文过大,又或者特性团队的工作分配不均匀时,就应该果断对已有限界上下文进行切分。 + +工作分配的基础在于“尽可能降低沟通成本”,遵循康威定律,沟通其实就是项目模块之间的依赖,这个过程同样不是一蹴而就的。康威认为: + + +在大多数情况下,最先产生的设计都不是最完美的,主导的系统设计理念可能需要更改。因此,组织的灵活性对于有效的设计有着举足轻重的作用,必须找到可以鼓励设计经理保持他们的组织精简与灵活的方法。 + + +特性团队正是用来解决这一问题的。换言之,当我们发现团队规模越来越大,失去了组织精简与灵活的优势,实际上就是在传递限界上下文过大的信号。项目经理对此需要有清醒认识,当团队规模违背了 2PTs 时,就该坐下来讨论一下如何细分团队的问题了。因此,按照团队合作的角度划分限界上下文,其实是一个动态的过程、演进的过程。 + +我在给某音乐网站进行领域驱动设计时,通过识别业务相关性划分了如下限界上下文。 + + +Media Player(online & 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 Developers,Developing 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)”。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/017理解上下文映射.md b/专栏/领域驱动设计实践(完)/017理解上下文映射.md new file mode 100644 index 0000000..aebf875 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/017理解上下文映射.md @@ -0,0 +1,29 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 017 理解上下文映射 + 一个软件系统通常被分为多个限界上下文,这是运用“分而治之”思想来降低业务复杂度的有效手段,设计的难题往往会停留在“如何分”,然而限界上下文之间的“怎么合”问题同样值得关注,分与合遵循的还是软件设计的最高原则——高内聚、松耦合。分是合的基础,基于内聚相关度进行合理的分配,可以在一定程度减少限界上下文之间不必要的关联。假设分配是合理的,则接下来的“合”就是要尽可能地降低彼此之间的耦合。 + +既然前面提及限界上下文的识别是一个迭代过程,当我们在思考限界上下文该如何协作时,倘若发现协作总有不合理之处,就可能会是一个“设计坏味道”的信号,它告诉我们:之前识别的限界上下文或有不妥,由是可以审视之前的设计,进而演进为更为准确的限界上下文划分。即使抛开对设计的促进作用,思考限界上下文是如何协作的,仍然格外重要,我们既要小心翼翼地维护限界上下文的边界,又需要它们彼此之间良好的协作,并思考协作的具体实现方式,这个思考过程既牵涉到逻辑架构层面,又与物理架构有关,足以引起我们的重视。 + +领域驱动设计通过上下文映射(Context Map) 来讨论限界上下文之间的协作问题,上下文映射是一种设计手段,Eric Evans 总结了诸如共享内核(Shared Kernel)、防腐层(Anticorruption Layer)、开放主机服务(Open Host Service)等多种模式。由于上下文映射本质上是与限界上下文一脉相承的,因此要掌握这些协作模式,应该从限界上下文的角度进行理解,着眼点还是在于“边界”。领域驱动设计认为:上下文映射是用于将限界上下文边界变得更清晰的重要工具。所以当我们正在为一些限界上下文的边界划分而左右为难时,不妨先放一放,在定下初步的限界上下文后,通过绘制上下文映射来检验,或许会有意外收获。 + +限界上下文的一个核心价值,就是利用边界来约束不同上下文的领域模型,以保证模型的一致性。然而,每个限界上下文都不是独立存在的,多数时候,都需要多个限界上下文通力协作,才能完成一个完整的用例场景。例如,客户之于商品、商品之于订单、订单之于支付,贯穿起来才能完成“购买商品”的核心流程。 + +两个限界上下文之间的关系是有方向的,领域驱动设计使用两个专门的术语来表述它们:“上游(Upstream)”和“下游(Downstream)”,在上下文映射图中,以 U 代表上游,D 代表下游,理解它们之间的关系,正如理解该术语隐喻的河流,自然是上游产生的变化会影响到下游,反之则不然。故而从上游到下游的关系方向,代表了影响产生的作用力,影响作用力的方向与程序员惯常理解的依赖方向恰恰相反,上游影响了下游,意味着下游依赖于上游。 + + + +在划分限界上下文的业务边界时,我们常常从“语义相关性”与“功能相关性”两个角度去判别职责划分的合理性。在上下文映射中,我发现之所以两个业务边界的限界上下文能产生上下游协作关系,皆源于二者的功能相关性,这种功能相关存在主次之分,往往是上游限界上下文作为下游限界上下文的功能支撑,这就意味着在当前的协作关系下,下游限界上下文中的用例才是核心领域。例如,订单与支付,下订单用例才是核心功能,支付功能作为支撑的公开服务而被调用;例如,邮件与文件共享,写邮件用例才是核心功能,上传附件作为支撑的公开服务而被调用;例如,项目管理与通知,分配任务用例才是核心功能,通知功能作为支撑的公开服务而被调用。巧的是,这种主次功能的调用关系,几乎对应的就是用例图中的包含用例或扩展用例。 + + + +如果我们通过用例图来帮助识别限界上下文,那么,用例图中的包含用例或扩展用例或许是一个不错的判断上下文协作关系的切入点。选择从包含或扩展关系切入,既可能确定了职责分离的逻辑边界,又可以确定协作关系的方向,这就是用例对领域驱动设计的价值所在了。 + +那么,如何将上下文映射运用到领域驱动的战略设计阶段?Eric Evans 为我们总结了常用的上下文映射模式。为了更好地理解这些模式,结合限界上下文对边界的控制力,再根据这些模式的本质,我将这些上下文映射模式分为了两大类:团队协作模式与通信集成模式。前者对应的其实是团队合作的工作边界,后者则从应用边界的角度分析了限界上下文之间应该如何进行通信才能提升设计质量。针对通信集成模式,结合领域驱动设计社区的技术发展,在原有上下文映射模式基础上,增加了发布/订阅事件模式。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/018上下文映射的团队协作模式.md b/专栏/领域驱动设计实践(完)/018上下文映射的团队协作模式.md new file mode 100644 index 0000000..337523b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/018上下文映射的团队协作模式.md @@ -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)。 + +共享内核往往被用来解决合作关系引入的问题。 + +共享内核是通过上下文映射识别出来的,通过它可以改进设计质量,弥补之前识别限界上下文的不足。与其说它是上下文映射的一种模式,不如说它是帮助我们识别隐藏限界上下文的模式,主要的驱动力就是“避免重复”,即 DRY(Don’t Repeat Yourself)原则的体现。在前面讲解通过应用边界识别限界上下文时,我提到了物流联运管理系统。运输、货站以及堆场都用到了作业与指令功能。显然,作业与指令功能放在运输、货站或堆场都不合理,这时就是运用“共享内核”的时机。为了避免重复,也为了避免不必要的依赖,可以提取出作业上下文。 + +当然,这种重用是需要付出代价的。Eric Evans 指出:“共享内核不能像其他设计部分那样自由更改,在做决定时需要与另一个团队协商。”至于修改产生的影响有多大,需要视该限界上下文与其他限界上下文之间的集成关系。尤其是大多数共享内核可能是多个限界上下文共同的上游,每次修改都可能牵一发而动全身。因此在对共享内核进行修改时,需要充分评估这种修改可能带来的影响。 + +客户方-供应方开发(Customer-Supplier Development) + +正常情况下,这是团队合作中最为常见的合作模式,体现的是上游(供应方)与下游(客户方)的合作关系。这种合作需要两个团队共同协商: + + +下游团队对上游团队提出的领域需求 +上游团队提供的服务采用什么样的协议与调用方式 +下游团队针对上游服务的测试策略 +上游团队给下游团队承诺的交付日期 +当上游服务的协议或调用方式发生变更时,该如何控制变更 + + +注意,在很多业务系统中,下游团队往往都不止一个。如何排定不同领域需求的优先级,如何针对不同的领域需求建立统一的抽象,都是上游团队需要考虑的问题。若上游服务还未提供,下游团队应采取模拟上游服务的方式来规避可能存在的集成风险,并且需要考虑上游团队不能按时履行交付承诺时的应对方案。上游团队需要及时就服务的任何变更与所有下游团队进行协商,而下游团队的领域需求一旦有变,也应及时告知上游团队。如果能够采用持续集成(Continuous Integration)为上、下游限界上下文建立集成测试、API 测试等自动化测试的构建与发布管道,可以更好地规避集成的风险,也能够更好地了解因为上游服务发生变更时对所有下游产生的影响。 + +例如,我们在设计通知(Notification)上下文时,作为上游服务的开发团队,需要考虑各种信息通知的领域需求。从通知类型看,可以是邮件、短信、微信推送和站内信息推送等多种方式。从通知格式看,可能是纯文本、HTML 或微信文章。从通知内容看,可以是固定内容,也可能需要提供通知模板,由调用者提供数据填充到模板中的正确位置。 + +设计该服务时,我们既要考虑这些通知服务实现的多样化,又要考虑服务调用的简单与一致性。至于发送的通知内容,则需要上游团队事先定义通知上下文的领域模型。该领域模型既要覆盖所有的业务场景,又要保证模型的稳定性,同时还必须注意维持通知上下文的职责边界。 + +譬如说,我们在通知上下文中定义了 Message 与 Template 领域对象,后者内部封装了一个HashMap类型的属性。Map 的 key 对应模板中的变量,value 则为实际填充的值。建模时,我们明确了通知上下文的职责,它仅负责模板内容正确地填充,并不负责对值的解析。这就是上游定义的契约,它清晰地勾勒了上下文之间协作的边界。倘若下游团队在填充通知模板的值时,还需要根据自己的业务规则进行运算,就应该在调用通知服务之前,首先在自己的限界上下文中进行计算,然后再将计算后的值作为模板的 value 传入。 + +遵奉者(Conformist) + +我们需要从两个角度来理解遵奉者模式,即需求的控制权与对领域模型的依赖。 + +一个正常的客户方-供应方开发模式,是上游团队满足下游团队提出的领域需求;但当需求的控制权发生了逆转,由上游团队来决定是响应还是拒绝下游团队提出的请求时,所谓的“遵奉者”模式就产生了。从这个角度来看,我们可以将遵奉者模式视为一种“反模式”。糟糕的是在现实的团队合作中,这种情形可谓频频发生,尤其是当两个团队分属于不同的管理者时,牵涉到的因素就不仅仅是与技术有关了。所以说领域驱动设计提出的“限界上下文”实践,影响的不仅仅是设计决策与技术实现,还与企业文化、组织结构直接有关。许多企业推行领域驱动设计之所以不够成功,除了团队成员不具备领域驱动设计的能力之外,还要归咎于企业文化和组织结构层面。例如,企业的组织结构人为地制造了领域专家与开发团队的壁垒,又比如两个限界上下文因为利益倾轧而导致协作障碍,而团队领导的求稳心态,也可能导致领域驱动设计“制造”的变化屡屡碰壁,无法将这种良性的“变化”顺利地传递下去。 + +遵奉者还有一层意思是下游限界上下文对上游限界上下文模型的追随。当我们选择对上游限界上下文的模型进行“追随”时,就意味着: + + +可以直接重用上游上下文的模型(好的) +减少了两个限界上下文之间模型的转换成本(好的) +使得下游限界上下文对上游产生了模型上的强依赖(坏的) + + +做出遵奉模型决策的前提是需要明确这两个上下文的统一语言是否存在一致性,因为限界上下文的边界本身就是为了维护这种一致性而存在的。理想状态下,即使是上下游关系的两个限界上下文都应该使用自己专属的领域模型,因为原则上不同限界上下文对统一语言的观察视角多少会出现分歧,但模型转换的成本确实会令你左右为难。设计总是如此,没有绝对好的解决方案,只能依据具体的业务场景权衡利弊得失,以求得到相对好(而不是最好)的方案。这是软件设计让人感觉棘手的原因,却也是它如此迷人的魅力所在。 + +分离方式(Separate Ways) + +分离方式的合作模式就是指两个限界上下文之间没有哪怕一丁点儿的丝毫关系。这种“无关系”仍然是一种关系,而且是一种最好的关系。这意味着我们无需考虑它们之间的集成与依赖,它们可以独立变化而互相不产生影响,还有什么比这更美好的呢? + +在典型的电商网站中,支付上下文与商品上下文之间就没有任何关系,二者是“分离方式”的体现。虽然从业务角度理解,客户购买商品,确乎是为商品进行支付,但在商品上下文中,我们关心的是商品的价格(另一种可能是将价格作为一个独立的上下文),在支付上下文,关注的却是每笔交易的金额。商品价格影响的是订单上下文,支付上下文会作为订单上下文的上游,被订单上下文调用,但这种调用传递的是每条订单的总金额,支付上下文并不关心每笔订单究竟包含了哪些商品。唯一让支付上下文与商品上下文之间可能存在关联的因素,是二者的领域模型中都需要 Money 值对象。我们可以在这两个限界上下文中重复定义 Money 值对象。如果 Money 值对象其实还牵涉到复杂的货币转换以及高精度的运算逻辑,我宁可将类似 Money 这样的对象剥离到单独的上下文中,例如单独拎出来一个货币上下文。此时的货币上下文其实是支付上下文与商品上下文的共享内核: + + + +“分离方式”的映射模式看起来容易识别,然而一旦系统的领域知识变得越来越复杂,导致多个限界上下文之间存在错综复杂的关系时,要识别两个限界上下文之间压根没有一点关系,就需要敏锐的“视力”了。这种没有关系的关系似乎无足轻重,其实不然,它对改进设计质量以及团队组织都有较大帮助。两个毫无交流与协作关系的团队看似冷漠无情,然而,正是这种“无情”才能促进它们独立发展,彼此不受影响。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/019上下文映射的通信集成模式.md b/专栏/领域驱动设计实践(完)/019上下文映射的通信集成模式.md new file mode 100644 index 0000000..e631a5b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/019上下文映射的通信集成模式.md @@ -0,0 +1,160 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 019 上下文映射的通信集成模式 + 无论采用何种设计,限界上下文之间的协作都是不可避免的,应用边界的上下文映射模式会以更加积极的态度来应对这种不可避免的协作;从设计的角度来讲,就是不遗余力地降低限界上下文之间的耦合关系。防腐层与开放主机服务的目的正是如此。 + +防腐层(Anticorruption Layer) + +防腐层其实是设计思想“间接”的一种体现。在架构层面,通过引入一个间接的层,就可以有效隔离限界上下文之间的耦合,这个间接的防腐层还可以扮演“适配器”的角色、“调停者”的角色、“外观”的角色,没错,这都是 GOF 设计模式中常见的几种结构型模式。 + +防腐层往往属于下游限界上下文,用以隔绝上游限界上下文可能发生的变化。因为不管是遵奉者模式,还是客户方-供应方模式,下游团队终究可能面临不可掌控的上游变化。在防腐层中定义一个映射上游限界上下文的服务接口,就可以将掌控权控制在下游团队中,即使上游发生了变化,影响的也仅仅是防腐层中的单一变化点,只要防腐层的接口不变,下游限界上下文的其他实现就不会受到影响。 + +我们可以通过下图来对比引入防腐层的价值: + + + +显然,在没有引入防腐层时,下游上下文可能存在多处对上游上下文领域模型的依赖,一旦上游发生变更,就会影响到下游的多处实现;引入防腐层后,之前产生的多处依赖转为对防腐层的依赖,再由防腐层指向上游上下文,形成单一依赖。上游变更时,影响的仅仅是防腐层,下游上下文自身并未受到影响。 + +用以对付遗留系统时,防腐层可谓首选利刃。我在前面讲解限界上下文对遗留系统的应对时,已经述及采用“抽象分支”与“防腐层”的手法。对于遗留系统,我们不能粗暴地用新系统取代它,而应采用渐进的手段尽可能重用它的资产,剔除不好的设计与实现,完成逐步替换;我们可以将遗留系统视为一个整体的限界上下文,然后为调用它的下游上下文建立防腐层。由于防腐层是我们自己掌控的,就可以在其内动动手脚,例如,从调用者角度思考需要公开的服务接口,并引入领域驱动设计为其提炼出清晰的领域模型,然后再从遗留系统中去寻找对应的实现,慢慢将合适的代码搬移过来,适时对其重构。这种做法既保有了新设计的新鲜感,不受技术债的影响,又不至于走向极端,对旧有系统大动干戈,可谓选择了一条“中庸之道”,能够新旧并存地小步前行。 + +开放主机服务(Open Host Service) + +如果说防腐层是下游限界上下文对抗上游变化的利器,那么开放主机服务就是上游服务用来吸引更多下游调用者的诱饵。设计开放主机服务,就是定义公开服务的协议,包括通信的方式、传递消息的格式(协议)。同时,也可视为是一种承诺,保证开放的服务不会轻易做出变化。 + +开放主机服务常常与发布语言(Published Language)模式结合起来使用。当然,在定义这样的公开服务时,为了被更多调用者使用,需要力求语言的标准化,在分布式系统中,通常采用 RPC(Protocol Buffer)、WebService 或 RESTful。若使用消息队列中间件,则需要事先定义消息的格式,例如,在我参与过的一个分布式 CIMS(计算集成制造系统)中,客户端与服务端以及服务端之间的通信皆以消息形式传递,我们定义了如下的消息格式: + +Message——Name + ——ID + ——Body(MessageItemSequence) + ——Value + ——Item(MessageItem) + ——SubValue + ——SubItem(MessageItem) + + + +采用这种消息格式,几乎所有的分布式服务都可以抽象为这样的接口: + +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 缩写代表开放主机服务。 + +发布/订阅事件 + +即使是确定了发布语言规范的开放主机服务,仍然会导致两个上下文之间存在耦合关系,下游限界上下文必须知道上游服务的 ABC(Address、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)场景。这种协作模式往往用于事件驱动架构或者 CQRS(Command Query Responsibility Segregation,命令查询职责分离)架构模式中。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/020辨别限界上下文的协作关系(上).md b/专栏/领域驱动设计实践(完)/020辨别限界上下文的协作关系(上).md new file mode 100644 index 0000000..c5ec96b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/020辨别限界上下文的协作关系(上).md @@ -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 应该排除在这个模型之外。 + +当然,这一观点亦存在争议,例如,四色建模就不这样认为,四色建模建议在时标性对象与作为人的实体对象之间引入角色对象,也就是说,角色对象会作为领域模型的一份子。当然,我们不能直接给角色与模型的参与者划上等号。在 DCI(Data 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); +} + + + +显然,不同的职责分层会直接影响到我们对限界上下文协作关系的判断。归根结底,还是彼此之间需要了解的“知识”起着决定作用。我们应尽可能遵循“最小知识法则”,在保证职责合理分配的前提下,产生协作的限界上下文越少越好。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/021辨别限界上下文的协作关系(下).md b/专栏/领域驱动设计实践(完)/021辨别限界上下文的协作关系(下).md new file mode 100644 index 0000000..e253c00 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/021辨别限界上下文的协作关系(下).md @@ -0,0 +1,119 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 021 辨别限界上下文的协作关系(下) + 领域模型产生的依赖 + +针对领域行为产生的依赖,我们可以通过抽象接口来解耦。例如,前面提到订单上下文对促销上下文的调用就通过引入防腐层(ACL)解除了对促销上下文的直接依赖;然而,限界上下文依赖的领域模型呢,又该如何处理? + +与领域行为相同,我们首先还是要判断限界上下文是否真正对别的领域模型产生了依赖!例如,要查询客户拥有的所有订单信息,应该像如下代码那样将订单列表当做客户的一个属性吗? + +public class Customer { + private List saleOrders; + public List getSaleOrders() { + return saleOrders; + } +} + + + +如果采取这样的设计,自然就会在客户上下文中产生对 SaleOrder 领域模型的依赖,然而,这种实现并不可取。因为这样的设计会导致在加载 Customer 时,需要加载可能压根儿就用不上的List,影响了性能。虽然通过延迟加载可以在一定程度解决性能问题,但既然存在延迟加载,就说明二者不一定总是同时需要。故而,延迟加载成为了判断领域实体对象设计是否合理的标志。 + +那么,是否可以用查询方法来替换属性?例如: + +public class Customer { + public List saleOrders() { + // ... + } +} + + + +在领域驱动设计中,我们通常不会这样设计,而是引入资源库对象来履行查询职责。若要查询订单,则 SaleOrder 会作为聚合根,对应的 SaleOrderRepository 作为资源库被放到订单上下文。在分层架构中,资源库对象可能会被封装到应用服务中,也可能直接暴露给作为适配器的 REST 服务中,例如,定义为: + +@Path("/saleorder-context/saleorders/{customerId}") +public class SaleOrderController { + @Autowired + private SaleOrderRepository repository; + + public List 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 与存储过程中蕴含的业务逻辑进行迁移,还可能存在迁移业务逻辑时破坏原有功能的风险。选择架构调整,需得管理层具备壮士扼腕的勇气与魄力才行。 + +无论是零共享架构的分库模式,还是数据库共享模式,我们都需要尽量避免因为在数据库层面引起多个限界上下文的依赖。获取数据有多种方式,除了通过领域模型中聚合根的资源库访问数据之外,我们也可以通过数据同步的方式,对多个限界上下文的数据进行整合。例如,电商网站的推荐系统,它将作为整个系统中一个独立的限界上下文。为了获得更加精准精细的推荐结果,推荐系统需要获取买家的访问日志、浏览与购买的历史记录、评价记录,需要获得商品的类别、价格、销售量、评价分数等属性,需要获取订单的详细记录,是否有退换货等一系列的信息。但这并不意味着推荐上下文会作为客户上下文、商品上下文、订单上下文等的下游客户方,也未必需要在数据库层面对多张表执行关联操作。 + +推荐算法的数据分析往往是一个大数据量分析,它需要获得的数据通常存储在扮演 OLTP(On-Line Transaction Processing,联机事务处理)角色的业务数据库。业务数据库是支撑系统业务应用的主要阵地,并被各种请求频繁读写。倘若该数据库成为瓶颈,有可能会影响到整个电商系统的运行;倘若推荐系统通过上游限界上下文的服务从各自的数据库中加载相关数据,并存入到内存中进行分析,会大量耗用网络资源和内存资源,影响电商网站的业务系统,也无法保证推荐系统的性能需求。 + +从数据分析理论来说,作为 OLTP 的业务数据库是面向业务进行数据设计的,这些数据甚至可能独立存在,并未形成数据仓库的主题数据特征,即集成了多个业务数据库,并能全面一致体现历史数据变化。因此,推荐系统需要利用大数据的采集技术,通过离线或实时流处理方式采集来自多数据源的多样化数据,然后可结合数据仓库技术,为其建立主题数据区和集市数据区,为 OLAP(On-Line Analytical Processing,联机分析处理)提供支撑,也为如协同决策这样的推荐算法提供了数据支持。 + +当然,推荐系统需要“知道”的数据不仅限于单纯的客户数据、商品数据与订单数据,还包括针对客户访问与购买商品的行为数据,如查询商品信息、添加购物车、添加订单、提交评论等行为产生的数据,这些行为数据未必存储在业务数据库中,相反可能会以如下形式存储: + + +日志:即记录这些行为数据为日志信息。我们可以将每次产生的日志存放到 ElasticSearch 中,并作为推荐系统要访问的数据库,常见架构就是所谓的 ELK(ElasticSearch + LogStash + Kibana)架构。 +事件:倘若采用事件溯源(Event Sourcing)模式,每次行为都会触发一个事件,并通过事件存储(Event Store)将它们存储到对应的数据库中,以待推荐系统读取这些事件溯源数据,结合业务数据运用推荐算法进行计算。 + + +由于推荐系统需要分析的数据已经通过专门的数据采集器完成了多数据源数据的采集,并写入到属于推荐上下文的主题数据库中,因而并不存在与其他业务数据库之间的依赖。从实现看,推荐上下文作为一个独立的限界上下文,与其他限界上下文之间并不存在依赖关系,属于上下文映射的“分离方式”模式。从这个例子获得的经验是:技术方案有时候会影响到我们对上下文映射的识别。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/022认识分层架构.md b/专栏/领域驱动设计实践(完)/022认识分层架构.md new file mode 100644 index 0000000..084822b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/022认识分层架构.md @@ -0,0 +1,119 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 022 认识分层架构 + 分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行;此外,分层架构模式还是隔离业务复杂度与技术复杂度的利器,《领域驱动设计模式、原理与实践》这样写道: + + +为了避免将代码库变成大泥球(BBoM)并因此减弱领域模型的完整性且最终减弱可用性,系统架构要支持技术复杂性与领域复杂性的分离。引起技术实现发生变化的原因与引起领域逻辑发生变化的原因显然不同,这就导致基础设施和领域逻辑问题会以不同速率发生变化。 + + +这里所谓的“以不同速率发生变化”,其实就是引起变化的原因各有不同,这正好是单一职责原则(Single-Responsibility Principle,SRP)的体现。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 Principle,DIP)提出了对这种自顶向下依赖的挑战,它要求“高层模块不应该依赖于低层模块,二者都应该依赖于抽象”,这个原则正本清源,给了我们严重警告——谁规定在分层架构中,依赖就一定要沿着自顶向下的方向传递?我们常常理解依赖,是因为被依赖方需要为依赖方(调用方)提供功能支撑,这是从功能重用的角度来考虑的。但我们不能忽略变化对系统产生的影响!与建造房屋一样,我们自然希望分层的模块“构建”在稳定的模块之上,谁更稳定?抽象更稳定。因此,依赖倒置原则隐含的本质是:我们要依赖不变或稳定的元素(类、模块或层),也就是该原则的第二句话:抽象不应该依赖于细节,细节应该依赖于抽象。 + +这一原则实际是“面向接口设计”原则的体现,即“针对接口编程,而不是针对实现编程”。高层模块对低层模块的实现是一无所知的,带来的好处是: + + +低层模块的细节实现可以独立变化,避免变化对高层模块产生污染 +在编译时,高层模块可以独立于低层模块单独存在 +对于高层模块而言,低层模块的实现是可替换的 + + +倘若高层依赖于低层的抽象,必然会面对一个问题:如何把具体的实现传递给高层的类?由于在高层通过接口隔离了对具体实现的依赖,就意味着这个具体依赖被转移到了外部,究竟使用哪一种具体实现,由外部的调用者来决定。只有在运行调用者代码时,才将外面的依赖传递给高层的类。Martin Fowler 形象地将这种机制称为“依赖注入(Dependency injection)”。 + +为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来。 + +层之间的协作并不一定是自顶向下的传递通信,也有可能是自底向上通信。例如,在 CIMS(计算机集成制造系统)中,往往会由低层的设备监测系统监测(侦听)设备状态的变化。当状态发生变化时,需要将变化的状态通知到上层的业务系统。如果说自顶向下的消息传递往往被描述为“请求(或调用)”,则自底向上的消息传递则往往被形象地称之为“通知”。倘若我们颠倒一下方向,自然也可以视为这是上层对下层的观察,故而可以运用观察者模式(Observer Pattern),在上层定义 Observer 接口,并提供 update() 方法供下层在感知状态发生变更时调用;或者,我们也可以认为这是一种回调机制。虽然本质上这并非回调,但设计原理是一样的。 + +如果采用了观察者模式,则与前面讲述的依赖倒置原则有差相仿佛之意,因为下层为了通知上层,需要调用上层提供的 Observer 接口。如此看来,无论是上层对下层的“请求(或调用)”,抑或下层对上层的“通知”,都颠覆了我们固有思维中那种高层依赖低层的理解。 + +现在,我们对分层架构有了更清醒的认识。我们必须要打破那种谈分层架构必为经典三层架构又或领域驱动设计推荐的四层架构这种固有思维,而是将分层视为关注点分离的水平抽象层次的体现。既然如此,架构的抽象层数就不是固定的,甚至每一层的名称也未必遵循固有(经典)的分层架构要求。设计系统的层需得结合该系统的具体业务场景而定。当然,我们也要认识到层次多少的利弊:过多的层会引入太多的间接而增加不必要的开支,层太少又可能导致关注点不够分离,导致系统的结构不合理。 + +我们还需要正视架构中各层之间的协作关系,打破高层依赖低层的固有思维,从解除耦合(或降低耦合)的角度探索层之间可能的协作关系。另外,我们还需要确定分层的架构原则(或约束),例如是否允许跨层调用,即每一层都可以使用比它低的所有层的服务,而不仅仅是相邻低层。这就是所谓的“松散分层系统(Relaxed Layered System)”。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/023分层架构的演化.md b/专栏/领域驱动设计实践(完)/023分层架构的演化.md new file mode 100644 index 0000000..d6c36b2 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/023分层架构的演化.md @@ -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 语言来实现,遵循整洁架构的设计思想,则所有领域模型对象都应该是 POJO(Plain 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 上,作者在文章的开篇对老马表示了致谢。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/024领域驱动架构的演进.md b/专栏/领域驱动设计实践(完)/024领域驱动架构的演进.md new file mode 100644 index 0000000..f375e53 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/024领域驱动架构的演进.md @@ -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 的转换,又或者考虑引入所谓“DTO(Data Transfer Object,数据传输对象)”,但这些都只限于后端 API 协议的设计。 + +准确地讲,前端可以视为是与基础设施层组件进行交互的外部资源,如前面整洁架构中的 Web 组件与 UI 组件。为了简化前端与后端的通信集成,我们通常会为系统引入一个开放主机服务(OHS),为前端提供统一而标准的服务接口。该接口实际上就是之前整洁架构中提及的 Controllers 组件,也即我提出的基础设施层的北向网关。于是,分层架构就演变为: + + + +这个分层架构展现了“离经叛道”的一面,因为基础设施层在这里出现了两次,但同时也充分说明了基础设施层的命名存在不足。当我们提及基础设施(Infrastructure)时,总还是会想当然地将其视为最基础的层。同时,这个架构也凸显了分层架构在表现力方面的缺陷。 + +引入应用层 + +即使我们分离了前后端,又引入了扮演北向网关角色的 Controllers,都不可规避一个问题,那就是领域层的设计粒度过细。由于有了 Controllers,我们可以将 Controllers 看成是领域层的客户端,这就使得它需要与封装了 Entity 与 Value Object 的 Aggregate、Services 以及抽象的 Repositories 接口协作。基于 KISS(Keep 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. (封装访问外部系统或资源行为的对象。)基础设施层要做的事情不正是封装对外部系统或资源的访问吗?至于“适配”的语义,仅仅是这种封装的实现模式罢了,更何况在这些组件中,不仅仅做了适配的工作。基于此,我才将这些组件统统视为“网关”,并根据其方向分别划分为北向网关与南向网关。理解网关的含义,可以帮助我们更好地理解基础设施层的本质。扮演网关角色的组件其实是一个出入口(某种情况下,网关更符合六边形架构中端口+适配器的组合概念),所以它们的行为特征是:网关组件自身会参与到业务中,但真正做的事情只是对业务的支撑,提供了与业务逻辑无关的基础功能实现。 + +经历了多次演进,我们的分层架构终于在避免贫血模型的同时保证了领域逻辑的纯粹性,有效地隔离了业务复杂度与技术复杂度。演进后的分层架构既遵循了整洁架构思想,又参考了六边形架构与微服务架构的特点。但我们不能说这样的分层架构就是尽善尽美的,更不能僵化地将演化得来的分层架构视为唯一的标准。分层架构是一种架构模式,遵循了“关注点分离”原则。因此,在针对不同限界上下文进行分层架构设计时,还需要结合当前限界上下文的特点进行设计,合理分层,保证结构的清晰和简单。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/025案例层次的职责与协作关系(图文篇).md b/专栏/领域驱动设计实践(完)/025案例层次的职责与协作关系(图文篇).md new file mode 100644 index 0000000..c7fee87 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/025案例层次的职责与协作关系(图文篇).md @@ -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 APIs,EmailSender 调用的 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 + + + +这三个方案该如何选择?根本的出发点在于你对业务逻辑和应用逻辑的认知,进而是你对领域服务与应用服务的认知,这些内容,就留待战术设计部分来讨论。由于并不存在绝对完美的正确答案,因此我的建议是在满足功能需求与松散耦合的前提下,请尽量选择更简单的方案。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/026限界上下文与架构.md b/专栏/领域驱动设计实践(完)/026限界上下文与架构.md new file mode 100644 index 0000000..450ba42 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/026限界上下文与架构.md @@ -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 规则的小团队去治理它。然而,这种架构的复杂度也不可低估。限界上下文之间的通信是跨进程的,我们需要考虑通信的健壮性。数据库是完全分离的,当需要关联之间的数据时,需得跨限界上下文去访问,无法享受数据库自身提供的关联福利。由于每个限界上下文都是分布式的,如何保证数据的一致性也是一件棘手的问题。当整个系统都被分解成一个个可以独立部署的限界上下文时,运维与监控的复杂度也随之而剧增。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/027限界上下文对架构的影响.md b/专栏/领域驱动设计实践(完)/027限界上下文对架构的影响.md new file mode 100644 index 0000000..a305b3d --- /dev/null +++ b/专栏/领域驱动设计实践(完)/027限界上下文对架构的影响.md @@ -0,0 +1,120 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 027 限界上下文对架构的影响 + 通信边界对架构的影响 + +限界上下文的通信边界会对系统的架构产生直接的影响,在此之前,我们需要理清几个和边界有关的概念。如前所述,我提出了限界上下文的通信边界的概念,并将其分为进程内通信与进程间通信两种方式。在 Toby Clemson 给出的微服务架构中,则将逻辑边界视为整个微服务的边界,而将微服务代码模型中的所有模块视为在同一个网络边界内。但我认为在引入了虚拟化以及容器技术后,仍将这种边界描述为网络边界似乎并不准确,因此我以进程边界来表示前面提到的通信边界。 + +显然,倘若限界上下文之间采用进程间通信,则每个限界上下文就可以认为是一个微服务——对于微服务,我更愿意用进程边界来界定代码模型的部署与运行。 + +无论是网络边界,还是进程边界,都可以视为物理边界;而代码模型中对于层以及模块的划分,则属于逻辑边界的范畴。逻辑边界有时候会和物理边界重合,但这仅仅是针对代码模型而言。一个系统多数情况下都会访问其物理边界之外的外部资源,如此看来,一个系统的逻辑边界往往要大于物理边界。 + +在进行架构设计时,我们往往会将整个系统的架构划分为多个不同的视图,其中最主要的视图就是逻辑视图和物理视图,这是我们看待系统的两种不同视角。前者关注代码结构、层次以及职责的分配,后者关注部署、运行以及资源的分配,这两种视图都需要考虑限界上下文以及它们之间的协作关系。在考虑逻辑视图时,我们会为限界上下文履行的职责所吸引,同时又需得关注它们之间的协作,此时,就该物理视图粉墨登场了。若两个限界上下文的代码模型属于同一个物理边界,就是部署和运行在同一个进程中的好哥俩儿,调用方式变得直接,协作关系较为简单,我们只需要在实现时尽可能维护好逻辑边界即可。如果限界上下文代码模型的逻辑边界与物理边界完全重叠,要考虑的架构要素就变得复杂了。 + +对于跨进程边界进行协作的限界上下文,我建议为其绘制上下文映射,并通过六边形架构来确定二者之间的通信端口与通信协议。上游限界上下文公开的接口可以是基于 HTTP 的 REST 服务,也可以通过 RPC 访问远程对象,又或者利用消息中间件传递消息。选择的通信协议不同,传递的消息格式以及序列化机制也不同,为下游限界上下文建立的客户端也不相同。由于这种协作关系其实是一种分布式调用,自然存在分布式系统与身俱来的缺陷,例如,网络总是不可靠,维护数据一致性要受到 CAP 原则的约束。这时,就需要考虑服务调用的熔断来及时应对故障,避免因单一故障点带来整个微服务架构的连锁反应。我们还需要权衡数据一致性问题,若不要求严格的数据一致性,则可以引入最终一致性(BASE),如采用可靠事件模式、补偿模式或者 TCC(Try-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 Architecture,EDA)风格构建的微服务系统。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)”。 + +通过电商系统的这个案例,清晰地为我们勾勒出限界上下文、六边形与微服务“三位一体”的设计脉络,即它们的设计思想、设计原则与设计方法是互相促进互相融合的。在架构设计层面上,三者可谓浑然一体。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/028领域驱动设计的代码模型.md b/专栏/领域驱动设计实践(完)/028领域驱动设计的代码模型.md new file mode 100644 index 0000000..87ca45b --- /dev/null +++ b/专栏/领域驱动设计实践(完)/028领域驱动设计的代码模型.md @@ -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 中定义了服务消息 SendNotificationRequest;Order 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 下的相关类配合完成限界上下文之间的协作即可。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/029代码模型的架构决策.md b/专栏/领域驱动设计实践(完)/029代码模型的架构决策.md new file mode 100644 index 0000000..3779af4 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/029代码模型的架构决策.md @@ -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 在这里谈到的组合功能,目的是为了组装一个满足客户端调用的服务对象。但我认为定义这样专属的应用服务并非必须。归根结底,这个应用服务要做的事情就是对多个限界上下文领域模型的协调与组装。这种需求必然要结合具体的业务场景,例如订单对象需要组合来自不同限界上下文的商品信息、客户信息、店铺信息等。该业务场景虽然牵涉到多个限界上下文领域模型的协调,但必然存在一个主领域对应的限界上下文。这个限界上下文提供的应用服务才是该业务场景需要实现的业务价值,因此就应该将这个应用服务定义在当前限界上下文,而非整个系统架构的应用层,又或者为其建立一个新的廉价的限界上下文。而在该限界上下文内部,应用层或领域层可以通过防腐层与其他限界上下文协作,共同为这个业务提供支持。除非,这个业务场景要完成的业务目标不属于之前识别的任何一个限界上下文。 + +再来考虑用户展现层的场景。假设需要支持多种前端应用,且不同前端应用需要不同的视图模型和交互逻辑。考虑到前端资源有限,同时保证前端代码的业务无关性,我们可以在系统架构层面上,定义一个统一的接口层。这个接口层位于服务端,提供了与前端界面对应的服务端组件,并成为组成用户界面的一部分。在这个接口层中,我们可以为不同的前端提供不同的服务端组件。由于引入的这一接口层具有后端服务的特征,却又为前端提供服务,因而被称之为 BFF(Backends 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 上下文的领域模型就可以简化,例如直接运用事务脚本或表模块模式。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/030实践先启阶段的需求分析.md b/专栏/领域驱动设计实践(完)/030实践先启阶段的需求分析.md new file mode 100644 index 0000000..dc63b0a --- /dev/null +++ b/专栏/领域驱动设计实践(完)/030实践先启阶段的需求分析.md @@ -0,0 +1,274 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 030 实践 先启阶段的需求分析 + 从本课开始,我将通过一个完整的真实案例 EAS 系统来展示领域驱动的战略设计过程。通过 EAS 项目,我会把前面讲解的各个知识点贯穿起来,作为实践领域驱动设计的参考设计过程呈现出来。在这个战略设计过程中,曾经因为未曾识别出项目的业务愿景而让需求分析走了一段较长的弯路;因为没有就领域概念形成统一语言,而导致领域建模出现偏差;限界上下文的识别也经历了反复迭代与修改,并经历了领域驱动架构的演进,直至获得相对稳定的领域模型与代码模型。限于篇幅,我无法呈现整个设计过程的完整全貌,但也尽可能将设计过程中遭遇的典型问题、做出的设计决策进行了阐述,并给出了部分设计结果作为参考。 + + +通过访问 GitHub 上的 eas-ddd 项目 获得该项目的介绍与源代码,访问 eas-ddd 项目的 Wiki 可以获得 EAS 项目的需求与项目概况,限界上下文划分;访问问题列表可以获得该项目的任务列表。 + + +背景:企业应用套件 + +企业应用套件(Enterprise Application Suite,EAS)是一个根据软件集团公司应用信息化的要求而开发的企业级应用软件。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)。这种协作关系正好体现了打破部门之间信息壁垒的愿望。由此,我们就可以绘制出整个系统的核心流程: + + + +作为核心流程的子流程,项目管理流程与招聘流程是更低一级的业务流程。在先启阶段,如果为了获得更加准确的主故事列表,仍然有必要进一步细化这些子流程。从敏捷开发的角度讲,我们也可以将这些流程的细化放到对应迭代的需求分析活动中,以便于尽快完成先启阶段,进入到项目的正式迭代阶段。毕竟在确定了产品的待办项(史诗级故事与主故事)之后,已经足以帮助团队确定发布与迭代计划了。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/031实践先启阶段的领域场景分析(上).md b/专栏/领域驱动设计实践(完)/031实践先启阶段的领域场景分析(上).md new file mode 100644 index 0000000..22ba859 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/031实践先启阶段的领域场景分析(上).md @@ -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)。每个参与者的用例图或许大小不一,粒度不均,但自身是完全独立的,参与者之间(除了存在泛化关系的参与者)的用例图互不干扰,清晰地勾勒出各自观察视角得到的领域行为。 + + + + \ No newline at end of file diff --git a/专栏/领域驱动设计实践(完)/032实践先启阶段的领域场景分析(下).md b/专栏/领域驱动设计实践(完)/032实践先启阶段的领域场景分析(下).md new file mode 100644 index 0000000..d322014 --- /dev/null +++ b/专栏/领域驱动设计实践(完)/032实践先启阶段的领域场景分析(下).md @@ -0,0 +1,132 @@ + + + 因收到Google相关通知,网站将会择期关闭。相关通知内容 + + + 032 实践 先启阶段的领域场景分析(下) + 如何有效地识别参与者的用例 + +前述内容通过用例形式将所有的主故事都转换成了与参与者有关的用例,那么,在识别用例时,是否有什么经验可循呢? + +用例关系的确定 + +一个用例图,往往体现了参与者与用例之间的使用关系,用例与用例之间的包含或扩展关系,有时候还存在用例之间的泛化关系,确定用例之间的关系很重要。在识别用例时,思考参与者与用例之间的关系会成为一个不错的设计起点。尤其在先启阶段,我们识别的用例体现了 Cockburn 提出的用例层次中的用户目标层,这恰好对应用例与参与者的“使用(Use)”关系。从领域场景分析的角度看,这个使用关系代表了业务价值。在确定了参与者后,你就可以结合主故事与领域场景,询问自己:“在这个领域场景下,该参与者的用户目标是什么?”由此,可以帮助我们确定该用例是否主用例。 + +正如对项目用例图中的分析,如果考虑编码实现的本质,则问题所属迭代、承担人以及问题状态都是问题(Issue)的属性;然而在用例图中,我却以“指定问题所属的迭代”、“分配问题给项目成员”、“更新问题状态”此三个主用例与“编辑问题信息”平级,因为它们在项目管理中都具有不可替代的业务价值。 + +与之相反,包含用例与扩展用例是为具有业务价值的主用例提供支持和服务的,识别它们既可以丰富和完善业务逻辑,又可以在后续的用例边界找到属于通用子领域或支撑子领域的业务内容。这些不直接提供业务价值的用例恰好可能组成单独的限界上下文。例如,在前面给出的诸多用例图中,诸如“上传附件”、“通知评估人”等用例主要以扩展用例的形式呈现,这些扩展用例体现了各自内聚的关注点,即文件共享与消息通知。 + +包含用例与扩展用例之间的区别在于两个用例之间的“粘性”。包含用例为主用例不可缺少之业务环节,如“指定项目经理”包含用例之于“立项”主用例,如果缺少了指定项目经理操作,立项就是不完整的。扩展用例为主用例功能之补充,如“通知立项”扩展用例之于“立项”主用例,即使没有通知立项的相关干系人,也不妨碍立项工作的完成。作为包含用例或扩展用例本身,又可以有属于自己的包含用例或扩展用例,例如“通知项目经理”对“指定项目经理”的扩展: + + + +从功能相关性看,“立项”与“指定项目经理”用例是强相关的,“通知立项”与“立项”用例是弱相关的。因此,对包含和扩展用例的识别往往会影响到后续对限界上下文的识别。 + +在识别用例图时,还要注意避免错误的用例关系识别。例如,在项目管理用例图中,团队最初为项目成员参与者识别出“接收问题分配”用例。结合业务场景对此进行检验:当项目经理将问题分配给项目成员后,在业务上确乎存在接受问题的行为;但该行为其实是一个线下行为,属于项目成员之间的一个口头表达;当问题分配给项目成员之后,就已经意味着该问题已经被项目成员接受。因此,这个用例是不合理的。 + +用例名应字斟句酌 + +在领域场景分析过程中,如果我们只满足于用例图的获得,无异于买椟还珠。用例图仅仅是我们获得的分析结果,但更重要的是我们获得用例图的过程,这其中的关键在于团队与领域专家的交流与合作。作为UML(Unified 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)主题包含的用例最少: + + + +识别主题边界不是求平衡,更不是为了让设计的模型更加好看,它的设计质量可能会直接影响到后续的限界上下文识别。或许内聚性的识别需要较强的分析能力和抽象能力,但只要我们遵循领域场景分析的设计思想,按部就班地通过业务流程识别参与者,再根据参与者驱动出清晰表达的用例图,最后再根据语义相关性和功能相关性识别主题边界,就能获得一个相对不错的场景分析结果。毕竟,这个分析过程是有章可循的,在知识的积累上也是层层递进的。整个过程不需要任何与技术实现有关的知识,非常利于领域专家与团队的共同协作和交流。 + + + + \ No newline at end of file