其他分享
首页 > 其他分享> > 万字长文谈谈领域驱动设计

万字长文谈谈领域驱动设计

作者:互联网

概述

概念可以简单描述某类事物,这类事物可以是实体也可以是问题。领域驱动设计是为了管理系统复杂性问题而生的一套方法论。

随着业务系统的复杂性不断提高,系统的性能和灵活性要求也会越来越高,如何构建一个扩展性强、可用性高的业务系统是需要我们不断思考的问题。

我们以交易系统为例,在互联网之初,实体商业占据绝对主导地位的时代,电子商务系统最初的目的就是把货物卖出去,业务需求很简单,就是一手付钱,一手交货,而更多的难点是在于如何让人们接受并认可在网络上进行交易。随着这几十年的发展,电商早已不是最初的样子,需求变为如何更快更多的把商品卖出去,于是产生出了层出不穷你算不清楚的促销活动,比如满减、凑单、会员价、拼团、优惠券等。你买东西的价格也许只有系统能真正算清楚。

系统的复杂性比起最初,呈几何倍的增长,如何控制并管理系统复杂度是我们需要在业务发展过程中需要解决的问题。复杂的业务各有各的复杂,而拆解之道也各有各的侧重,今天要介绍的是领域驱动设计如何帮助我们拆解需求,并建立一个灵活性高、可扩展的业务系统。

该内容为作者(知一)原创,首发在个人博客 https://noogel.xyz 和微信公众号『知一杂谈』,欢迎 关注、点赞、留言~

领域驱动设计在讲什么

领域驱动设计中的领域是什么?我理解的是一个比行业更加细分的方向,比如互联网做电商业务是电商领域,电商中有专注交易的交易领域,做电子支付叫支付领域。领域范围可大可小,领域知识表示某些具有相关相关性知识的合集。

领域驱动设计是通过领域知识构建的领域模型来控制业务的复杂性,通过领域模型反映领域知识,构建更易维护的系统。解决软件难以理解,难以演化的问题。

上面的总结涉嫌鸡生蛋蛋生鸡的问题。其实领域模型和领域知识是迭代产生的,随着人类抽象总结而不断凝练而成的。拿之前讨论过的例子来说,一个电商领域专家可能脱口而出订单的概念,大家先入为主的很容易理解这个概念。

从人类历程来看最早出现的是物物交换的概念,后面逐渐变成等价货币交换,我们抽象的名词叫交易,再到后面你从我这里付一笔钱,我给你一个凭据,过段时间你来取货,我们管这叫购买凭据,进而逐渐演化成订单这个概念。

领域驱动设计的核心价值

领域驱动设计的核心目标是基于特定业务范围,通过统一业务概念(统一语言),将系统参与各方整合在一起,从而减少不同角色和环节的信息熵减问题。

领域模型是领域驱动设计的核心产出,它不仅能描述真实的业务逻辑和业务场景,也是系统实现的表达方式。领域模型的适应性能直接反应系统的扩展性上,能否使系统在增大时仍然保持敏捷。

领域驱动设计之所以更加流行,很大因素是领域驱动设计提供的方法论上与近些年流行的微服务有很好的匹配性,通过领域驱动设计方法清晰地识别业务边界,以此来指导微服务的拆分。 领域驱动设计提供的领域划分方法可以指导我们对微服务的拆分,以及对于演进式架构有很强的助力。

领域驱动设计的适用场景

通过上面对于领域驱动设计的介绍,可以提炼出三个主要作用:

  1. 统一通用语言,降低不同角色间的沟通成本。
  2. 通过战略设计划分子域、限界上下文,以此垂直拆解复杂度。
  3. 通过聚合的方式进行建模,以此水平拆解复杂度。

通过以上三个作用来逐步介绍领域驱动设计的适用场景。

多角色协作的业务场景

领域驱动设计中引入领域专家角色,是指对某个领域的概念和流程有着深入理解的一类人。开发人员与领域专家之间,他们掌握的知识存在巨大的差异。就比如电商领域专家清楚地了解交易单、订单、子单、售后、物流单、运单这些概念的准确含义,而开发人员更专注技术的运用,在沟通中如果没有达成一致的理解,沟通效率就会很差,甚至产生误解。

领域驱动设计提出从需求中提炼出统一语言,其实就是在两个不同的语言世界中进行正确翻译的过程。在多角色协作的场景中可以有效降低沟通成本,迭代式的探索和发现模型。

复杂业务场景进行业务拆解

上面我们提到现代电商促销方案层出不穷,决定一笔交易的金额有很多影响因素,而算价结果直接影响到这笔交易的支付金额,以及每件商品的实付金额。如果我们认为促销价格计算和交易联系很紧密就把他们放到了一起去开发维护,我想这个系统后面必定会难以维护,最终进行拆分。

而系统拆分的指导思想就是我们耳熟能详的六个字:『高内聚,低耦合。』 领域驱动设计有着一套完整的方法论,指导我们对复杂问题进行拆分、梳理各个子系统间的关系,帮助我们落地复杂系统。

领域驱动设计核心概念

领域驱动设计学习拦路虎之一就是众多的概念,第一次接触这些概念会有一定的理解成本,不过正是这些概念支撑起的领域驱动设计,接下来会以电商为例对其中的核心概念做介绍。

电商案例

网上购物已经成为我们生活中不可分割的一部分,作为一个用户而言我们经历的流程有以下几点:

  1. 从商品列表页面选择需要的商品。
  2. 查查商品的促销活动,凑凑满减。
  3. 在购物车选择需要买的商品下单。
  4. 下完单通过微信或者支付宝付钱。
  5. 然后等着物流送货上门。

作为电商的管理人员我们需要做的则是以下几点:

  1. 从采购点采购商品,存放到仓库。
  2. 编辑商品信息,上架售卖。
  3. 编辑一些优惠信息展示在平台上。
  4. 将用户下单的商品通知仓库发货。
  5. 营收成本的清结算。

电商平台作为一个复杂系统主要有多阶段、⻓链路、多角⾊参与、多信息互通的商品/服务交换过程的特点。而领域驱动设计中的概念能支撑我们将电商复杂流程拆解消化,并且建立一个易扩展、更稳定的系统。

通用语言和限界上下文

既然有多方协作参与系统的建设和运营,就需要沟通,而降低沟通成本的一个关键就是统一概念和认知,比如我们对于商品的认知,同样都是 iPhone 13,蓝色和粉色,128G 和 256G ,我们说卖掉了一个 iPhone 13 还是卖掉了一个 iPhone 13 蓝色 256G 要怎么表达,这时我们需要有两个概念 SKU 和 SPU 来区分,SKU 作为商品最小售卖单元表达后者,SPU 作为商品信息聚合的最小单位表达前者。

正是因为不同参与角色可能有不同的理解,为了降低大家沟通的障碍,提出了通用语言和限界上下文这两个重要概念。

使团队交流达成共识的能够明确简单清晰地描述业务规则和业务含义的语言就是通用语言。 解决各岗位的沟通障碍问题,促进不同岗位的和合作,确保业务需求的正确表达。通用语言贯穿于整个设计过程,基于通用语言可以开发出可读性更好的代码,能准确的把业务需求转化为代码。

界限上下文则是用来封装通用语言和领域对象,提供上下文环境,保证在上下文内的业务概念和流程等有一个确切的含义,没有二义性。

业务概念往往由领域专家带领团队统一通用语言,明确上下文边界,以结算单这个概念在订单上下文和结算上下文的差异来举例:

明确上下文边界后,我们跟不同岗位的人沟通即使使用相同词汇也能准确理解其含义。

领域专家和领域知识

领域驱动设计强调由领域专家带领大家进行领域建模。领域专家指的是对一个领域的概念和业务流程精通的人,能快速识别或预判业务风险并能给出有效解决方案的人。 他可以是各个岗位的人,包括一个开发也能成为领域专家。领域知识则是这个领域的各种概念和业务流程。

战略设计与战术设计

领域驱动设计作为一种设计方法论,从两个方向指导设计思想,提出了战略设计和战术设计的概念。

战略设计是从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言下的限界上下文。它是从顶层视角来审视我们的软件系统各个子模块之间的边界。

拿上面的流程举例来说明,一个有经验的领域专家会带领大家通过事件风暴建模的方法进行子域拆分,大致分为交易域、营销域、支付域、商品域、履约域。

战术设计则是从技术视角出发,侧重于领域模型的技术实现,完成软件开发和落地,它主要关注的是技术层面的实施。战术设计识别出来的是聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。


缓冲区

关于领域驱动设计的核心概念已经介绍了一部分,后面还有一部分。关于这些概念的涵盖范围见下图。

该内容为作者(知一)原创,首发在个人博客 https://noogel.xyz 和微信公众号『知一杂谈』,欢迎 关注、点赞、留言~


什么是领域模型

我们都不喜欢写 CRUD 的代码,只因为这些代码往往逻辑很简单,也不具备足够的扩展性,单一场景下可以很快开发出来,如果再加一个场景就又要开发一套,如果场景复杂并且不断变化,开发效率不仅会变慢,而且会更难以维护。下面通过支付系统来举例。

对于 CRUD 的实践来说,在对接支付渠道的时候,给每一家渠道都增加渠道单记录表,字段参照渠道参数定义的,对接微信时增加 wechat_trade 表,增加支付宝时增加 alipay_trade 表。问题就是当渠道增多时每次都建表显然不现实。

正常的做法则是,统一支付单记录,提取支付关键信息,通过总表和渠道表来记录,总表记录关键信息,把次要信息放入渠道表。相当于把支付单信息做了一次垂直拆分。

随着发展,新增了连续订阅业务,产品说需要在支付单中识别出是系统扣费还是用户主动付费的,这时你会想着扩列来支持,可是业务千变万化,不能每次都这样做。

其实软件开发中的许多问题,例如沟通问题、演化问题都和领域模型有关。领域模型是对领域内的概念类或现实世界中对象的可视化表示。它专注于分析问题领域本身,发掘重要的业务领域概念,并建立业务领域概念之间的关系。

实体和值对象

实体和值对象是组成领域模型的基础单元。

实体拥有唯一标识符,且标识符在历经各种状态变更后仍能保持一致。 对实体而言,重要的不是其属性,而是其延续性和标识,对象的延续性和标识会跨越甚至超出软件的生命周期。我们把这样的对象称为实体。从上面的实例来说,支付单有唯一的 ID,渠道单有自己的唯一 ID,它们都是实体。

当一个对象用来描述一个实物,而没有唯一的标识符,叫做值对象。 值对象本质就是一个集合,可以保证属性归类的清晰和概念的完整性。由于金额不能单独表达用户的消费额,需要由支付金额和货币类型组合才能表达,消费额是一组值对象。

聚合与聚合根

聚合是领域模型的具体表达。

聚合是业务和逻辑紧密关联的实体和值对象组合而成,聚合是数据修改和持久化的基本单元,一个聚合对应一个数据的持久化。 聚合在 DDD 分层架构中属于领域层,一个聚合提供一个业务核心能力,领域层包含了多个聚合,聚合内的实体以充血模型实现个体业务能力,以及业务逻辑的高内聚。

聚合根也叫做根实体,它不仅仅是实体,还是实体的管理者。 聚合之间通过聚合根关联引用,如果需要访问其他聚合的实体,先访问聚合根,再导航到聚合内部的实体。即外部对象不能直接访问聚合内的实体。

拿上面支付的例子来说,支付是一个聚合,支付单是聚合根,渠道单是依附于聚合根的另一个实体,渠道单的所有行为都要通过支付单进行操作。

上面说到聚合之间通过聚合根关联引用,一个实体是否属于聚合根取决于所处的聚合。在退款聚合中,退款单是聚合根,绑定的支付单,在这里支付单是普通实体。所以是否是聚合根取决于具体场景。

聚合的特点:高内聚、低耦合,它是领域模型中最底层的边界,可以作为拆分微服务的最小单位。

从事件风暴建模学到什么

在这里我说一下电商中比较核心的一个流程。在京东购物我们会选择很多需要的商品添加到购物车,在双十一的时候会凑单满减,然后从购物车选中下单。现在我们要设计的部分是用户在选择多件商品时自动给用户使用上最优的多种促销活动,在用户下单的时候能够计算好用户应该付多少钱,每件商品分别应付和优惠多少钱。后面的表达我会用算价来代表这个流程。

领域知识的构成

在领域驱动设计中很强调领域专家这角色,与团队人员共同协作完成任务。而往往团队人员就拥有领域专家所拥有的部分知识,从而承担领域专家的职责,那么剩下的领域知识就需要靠团队人员借助外援来填补,方式包括但不限于以下三种方式:

  1. 通过网络渠道(论文、文章、书籍)获得。
  2. 请教身边有相关经验的朋友。
  3. 通过竞品分析获得。

当我们团队获得该领域下主要的领域知识后,需要结合实际需求进行战略设计和战术设计,就可以通过事件风暴建模方法进行领域建模。

本来是想着拿实际的例子来讲一遍事件风暴建模的过程,现在想想与其照本宣科的讲知识,不如写写经验和感悟来的实在。

事件风暴 VS 传统开发

事件风暴建模的标准流程可以很轻松地找到,这里不再赘述。主要说下从传统软件开发模型到领域驱动设计的领域建模,发生了什么变化。

传统模式:产品需求->需求分析->详细设计->ER模型->UML 设计
DDD 模式:事件风暴->产品愿景->场景分析->领域建模->微服务拆分与设计。

在传统模式下的产出的是可直接落地的设计结果,但是缺乏顶层设计,对于后期的变更维护难以高效支撑。而 DDD 的关注点更多的是顶层设计和概念模型,概念模型并不是可直接落地的结果,这样的优势便是在后期的扩展和变更中更容易。

子域拆分的关键经验

关于如何拆分子域,看了很多的内容后得到的一句话:『凭经验』,这个就让人很糊涂,我如何知道我拆分的是否准确。

当我带着问题去找书查资料,收获还是比较快的,有一段话驱散了一部分迷雾:『领域的边界划分不断演绎,只要发现复杂性凝聚的地方,就划定为有界上下文,割裂它与其他系统的关系,并派出精兵强将专门对付。』它给了我两个点醒:

  1. 领域的边界是不断演绎的。
  2. 领域内部是高内聚的,领域间是低耦合的。

从这两点出发,可以通过以下两点执行:

  1. 和领域专家沟通现在,并预判一下未来。
  2. 分析领域内头部公司的策略。

领域建模的关键经验

假定产品愿景是可行并且可执行的。在场景分析和领域建模的过程,有个通用的范式。

  1. 提取业务中的动词和名词识别为领域概念。
  2. 通过业务中的定语对领域概念进行归纳抽象。
  3. 对确定的领域概念进行关系确认。

由此我们可以得出领域分析模型,这是一个比较抽象的模型,此时还无法落地。从复杂性角度来看领域建模控制的是业务复杂性。

复杂性问题控制方式

在之前的文章中也提到过三点:

  1. 抽象
  2. 分治
  3. 领域知识

现在反过来看,提炼领域概念是抽象,子域拆分是分治,而要做到这两点的正需要的是领域知识。领域驱动设计不仅告诉了我们『道』,也告诉了我们『术』。

该内容为作者(知一)原创,首发在个人博客 https://noogel.xyz 和微信公众号『知一杂谈』,欢迎 关注、点赞、留言~

谈谈领域驱动设计的落地

前文提到了事件风暴产出的领域模型是概念模型,到实际落地还有些距离,而落地的结果也是各不相同,我觉得说落地,要先回顾一下领域驱动设计的两个作用。

  1. 通过战略设计拆分子域,指导微服务拆分。
  2. 通过事件风暴建立领域概念模型,指导代码设计。

也就是说领域驱动设计产出的结果是指导性的,并不是一个直接可落地的结果。落地的方案则是要通过架构设计和框架选择上来进行。架构是为了控制软件复杂性而做,就好像『一千个读者心中有一千个哈姆雷特』,不同人做架构不尽相同。下面说说我的落地方式。

架构演进

我们最初接触和使用的分层架构是三层的,三层架构解决了程序内部代码调用复杂和职责不清的问题,在 DDD 分层架构中的关于对象和服务被重新归类到不同分层中,确定了层与层之间的职责边界。DDD 提出了四层架构,其中最主要的变化是提出领域层的概念,需要领域专家对于业务知识的精准把握之上,根据领域设计方法建立领域模型,把变动较少的领域模型放入领域层,而多变的业务场景代码放入应用层。如下图对应三层到四层的演进过程。

分层架构的一个重要原则是每层只能与位于其下方的层发生耦合,可以简单分为以下两种:

这两种分层架构的耦合方式是各有利弊,在网络上对于他们也是各有各的见解。结合实际情况在开发中,更倾向于采用松散分层架构,但是要禁止用户接口层直接访问基础设施层,防止一些潜在的安全问题。

子域划分

基于现有三层架构,在其中增加 domain 包的形式增加领域服务层。不同的子域通过包来划分如下:

1
2
3
package noogel.xyz.domain.deal;  // 交易子域
package noogel.xyz.domain.quote; // 算价子域
package noogel.xyz.domain.promotion; // 促销子域

同一个领域服务下面再按照领域对象、领域服务、领域资源库、防腐层等方式组织。

1
2
3
4
package noogel.xyz.domain.xxx.repository;  // 资源库接口定义
package noogel.xyz.domain.xxx.entity; // 领域对象
package noogel.xyz.domain.xxx.facade; // 防腐层
package noogel.xyz.domain.xxx.service; // 领域服务

领域对象

领域驱动解决的一个问题就是对象的贫血问题。通过如下促销领域对象来说明,对于当前购买商品组合能否满足购买规则的检查逻辑不是放在服务层或者工具类中,而是由领域对象提供方法支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Getter
@ToString
@...
public class PromotionDo {
/**
* 业务幂等
*/
private String bizNo;
// 省略字段...
private Long beginTime;
private Long endTime;
private String desc;

/**
* 计算生效数据
* @param items
* @return
*/
public List<PromotionDo> calculateValid(List<ItemDo> items) {
switch (rule.getKind()) {
// ...
}
List<PromotionDo> promoDataList = new ArrayList<>();
// do sth ...
return promoDataList;
}
}

资源库(依赖倒置)

资源库对外的整体访问由 Repository 提供,它聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑。我们将资源库的接口定义放在领域层,而具体实现放在基础设施层。

1
2
3
4
package noogel.xyz.domain.xxx.repository;  // 资源库接口定义
package noogel.xyz.infrastructure.repository; // 资源库实现
package noogel.xyz.infrastructure.rpc; // RPC 服务
package noogel.xyz.infrastructure.dao; // 数据库访问对象

资源库接口定义,提供必要的入参,并且以领域对象的形式作为结果返回。至于组织返回的领域对象,交由具体实现类来实现,可以通过调用数据库、缓存系统、RPC 接口等形式来组织生成领域对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface PromotionRepository {
/**
* 保存 xx
* @param data 领域对象
* @return 唯一 key
*/
String create(PromotionDo data);

/**
* 批量更新状态
* @param key
* @param state
* @return
*/
boolean batchUpdateState(List<String> key, PromoState state);

/**
* 批量查询
* @param promoIds
* @return
*/
Map<String, PromotionDo> batchGetOnlineById(List<Long> ids);
}

防腐层

用来消除外部上下文结构差异的作用,也叫适配层。比如在算价上下文中需要调用促销上下文数据,不同的促销数据源提供了不同的接口和数据,这时就需要引入防腐层来屏蔽差异,防止外部上下文侵入领域内部影响代码模型。首先定义需要的数据接口规范。

1
2
3
4
5
6
7
8
9
10
public interface PromotionFacade {

/**
* 计算促销数据
*
* @param ctx
* @return
*/
List<PromotionData> calculatePromotion(PromotionContext ctx);
}

实现类来用处理外部数据的差异,按照接口要求封装数据,简化模型的复杂性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Promotion1Facade implements PromotionFacade {

@Override
public List<PromotionData> calculatePromotion(PromotionContext ctx) {
PromotionData promoData = PromotionData.of(...);
return Collections.singletonList(promoData);
}
}


public class Promotion2Facade implements PromotionFacade {

@Autowired
private RpcService rpcService;

@Override
public List<PromotionData> calculatePromotion(PromotionContext ctx) {
PromotionData data = new PromotionData();
// do sth ...
return data;
}
}

上下文集成

对于上下文集成的手段可以通过 RPC 服务、HTTP 服务、MQ 消息订阅。

领域服务

上面我们讲述了各个要素对于资源和行为的封装,业务逻辑的实现代码应该尽量放在聚合根边界内。但是总会遇到不适合放在聚合根上的业务逻辑,而此时领域服务就需要承载编排组合领域对象、资源库和防腐接口等一系列要素,提供对其它上下文的交互接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public interface PromotionService {
/**
* 创建促销
*
* @param item
* @return
*/
String createPromotion(CreatePromotionDto item);

/**
* 批量更新状态
*
* @param req
* @return
*/
boolean batchUpdatePromotion(BatchUpdatePromotionReqDto req);

/**
* 计算有效的促销
*
* @param req
* @return
*/
List<PromoResultDto> calculateValidPromotion(CalculateValidPromotionReqDto req);
}

落地延伸

DDD 的设计概念很多,学习成本比较高,于是我们组织了《实现领域驱动设计》的读书分享会,通过共读分享交流理解的方式,让大家对于 DDD 的设计方法和概念有了比较统一的认知。同时发现在做设计分享时,组内的认知比较一致,而对外的理解成本则会比较高。

不论我们怎样称呼应用层和领域层,但是四层架构的优势已经显而易见,对于电商交易这样一类相对复杂的系统而言。DDD 教会我们怎么拆分领域,如何沉淀领域模型,而如何组织领域服务提供业务功能上是匮乏的,下面是基于系统问题和业界资料总结的一个抽象框架,描述的是如何组合核心能力与业务场景,并提供一个配置化的灵活系统。

能力单元

提供基础能力的独立单元,只单纯依赖下游数据提供能力,职责比较单一,对应领域驱动设计的领域服务。

场景单元

通过编排不同能力单元,形成一个预定义的执行流程,叫做场景单元。场景单元有以下关键要素:

  1. 执行节点:执行节点负责转换出入参并调用能力单元或场景单元,返回结果给下一个节点。
  2. 条件控制:根据执行节点结果进行简单逻辑判断选择不同的执行路径。
  3. 干预策略:干预策略是场景的扩展点,通过预留的扩展点可以干预执行流程。

所以一个场景单元的实际处理通路由条件控制和干预策略决定。

策略配置服务

  1. 提供静态或动态的策略配置给场景单元使用。
  2. 基于节点维度的简单风控策略支持,比如限流、熔断等。

框架图

核心能力封装数据和行为,职责要单一且通用,对外提供完善的接口供场景调用,核心能力内部是高内聚的,能力外不能与其它能力模块发生直接耦合,只能通过场景进行间接耦合,要保证核心能力的职责单一性。

能力模型是指对于复杂场景进行归类和抽象得出的一个模型,可以用来解决某一类通用问题。能力模型既可以是由订单系统内部提供的,也可能是由外部系统通过 RPC 形式提供的一整套能力接口包装而得。

内部事件,由于能力之间不允许直接耦合,所以内部事件不允许在能力模块内部发送,只能由场景中进行控制发送,并且能力内部不允许直接监听,而应该把监听事件作为场景的一种入口,实现场景之间的依赖调用。

场景单元偏流程数据编排,需要组织和协调资源的代码被定义为流程。场景单元与策略服务耦合更重,通过策略服务控制场景流程图的走向,以此来实现系统配置化。

该内容为作者(知一)原创,首发在个人博客 https://noogel.xyz 和微信公众号『知一杂谈』,欢迎 关注、点赞、留言~

参考

《复杂软件设计之道:领域驱动设计全面解析与实战》 - 彭晨阳
《实现领域驱动设计》 - 沃恩·弗农
《解构领域驱动设计》 - 张逸
《DDD实战课》 - 极客时间

文章

https://insights.thoughtworks.cn/backend-development-ddd/
https://zhuanlan.zhihu.com/p/383427771
https://cloud.tencent.com/developer/article/1549817

标签:场景,聚合,领域,谈谈,设计,字长,驱动,上下文
来源: https://www.cnblogs.com/pyer/p/15856573.html