前言👀~
上一章我们介绍了JavaEE进阶的一些基础知识点,今天来讲解在Spring中的一个重要知识点Spring Web MVC,看完学不会Spring MVC来揍我
什么是 MVC?
举个例子:如果客户来公司要做一个软件先问前台view,然后前台知道后跟它说去某部门controller,然后某部门在把这个人带给对应的负责人啊等等去进行处理沟通也就是我们的model,最后结果原路返回。注意一下MVC 和 Spring MVC还是有点区别的
什么是SpringWeb?
什么是 Spring Web MVC?
上个文章中我们在创建Spring Boot项目的时候其实我们就涉及到了Spring MVC,Spring Boot是创建Spring MVC项目的一种方式,也可以通过其他方式创建Spring MVC项目,只要包含了web这个模块,我们就可以认为这是一个Spring MVC项目。一个项目可能是Spring Boot项目也可能是Spring MVC项目,Spring Boot是帮助我们快速搭建项目的,Spring MVC是其中的一个模块
此时Spring MVC整个流程是如下图所示
浏览器发送请求由我们的controller进行接收用户的请求,然后将请求传给对应的model进行处理,接着model进行处理后返回给controller,controller再把处理的结果传给view,注意这里view和MVC返回的还是有区别的,之前返回的是视图,现在返回的是视图所需要的数据,把这个返回的数据认为是view
总结来说,Spring MVC 是⼀个实现了 MVC 模式的 Web 框架
学习Spring MVC
如何通过浏览器和服务器进⾏交互?
1.建立连接
什么是路由映射呢?
@RequestMapping就类似一个门牌号,可修饰方法也可修饰类,修饰方法时如果类没有修饰的话我们可以直接通过你在RequestMapping中写的路径搭配上URL进行访问。如果是修饰了类也修饰了方法,此时访问地址为URL+类的路径+方法路径就是都要写上。建议都修饰!并且建议路径名最好和方法名一样
@RequestMapping既支持get请求也支持post请求,比较全能支持很多请求,并且可以限制请求方式使用method属性,下面使用postman进行演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m1")
public String m1() {
return "Hello RequestMapping";
}
}
GET请求演示
POST请求演示
限制请求后,如果没有使用指定的请求方式会报405表示请求的方式错误!!下面限制为GET请求
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping(value = "/m1", method = RequestMethod.GET)
public String m1() {
return "Hello RequestMapping";
}
}
补充:如果类加了注解@RestController,Spring才会去看这个类里面的方法有没有加 @RequestMapping 这个注解,这只是简单理解,后面就会明白什么意思了
Postman下载链接:https://www.filehorse.com/download-postman/68966/
2.请求
请求主要是学习如何传参,大白话就是前端的参数传入后端进行处理,处理后返回一个响应。我们可以使用postman工具进行校验,不限这个工具。不管使用哪种方式发送请求,对于我们后端来说没啥区别,我们就像厨师,管你客户是桌上二维码下单还是叫服务员点单,我们都还是一样的做法,我们只要负责接收订单即可
1.传递单个参数
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m2")
public String m2(String name) {
return "接收到的名字:" + name;
}
}
注意:参数名需要一样,参数顺序不一样没事。以及还要注意如果参数类型是基本数据类型的话必须要传值不然会报错!!!,所以在开发时建议使用包装类,它可以区分0和null。下面进行演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m2")
public String m2(int id) {
return "接收到的id:" + id;
}
}
然后是使用包装类型,下面进行演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m2")
public String m2(Integer id) {
return "接收到的id:" + id;
}
}
2.传递多个参数
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m2")
public String m2(Integer id, String name) {
return "接收到的id:" + id + " " + "接收到的名字:" + name;
}
注意:还是一样如果使用基本数据类型不传参会报错,参数的顺序可以调换不会报错
3.传递对象
先创建一个对象,属性如下
public class Student {
private Integer id;
private String name;
private String sexy;
}
接着传递对象,我们看看效果
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(Student student) {
return "接收到的Student:" + student;
}
}
后端参数重命名
下面代码中把name赋值给这个userName,name属于前端传来的参数,userName属于后端的参数
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestParam("name") String userName) {
return "接收到的名字:" + userName;
}
}
注意看参数名!
如果你不按@RequestParam注解里的参数名传递值,看下面的效果
但是我们可以手动设置参数不是必传参数,@RequestParam(value = "name", required = false,默认是true代表name是必传参数,请求中必须包含它。这样写了之后就不是必传参数,是可选的,下面进行演示,即使不传或者使用后端参数名称都没事
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestParam(value = "name", required = false) String userName) {
return "接收到的名字:" + userName;
}
}
4.传递数组/集合
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(String[] name) {
return "接收到的名字:" + Arrays.toString(name);
}
}
补充:参数之间还可以使用逗号分割,使用的是Chrome浏览器会自动进行转码urlencode
接下来是传递集合的演示,首先是错误演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(List<String> name) {
return "接收到的名字:" + name;
}
}
正确的应该使用@RequestParam 绑定参数关系,下面是演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestParam List<String> name) {
return "接收到的名字:" + name;
}
}
补充:@GetMapping注解是@RequestMapping的一个特化形式,专门用于处理GET请求。@PostMapping同样是@RequestMapping的一个特化形式,专门用于处理POST请求,这两者主要是用于简化代码和提高可读性,日常工作中也比较少用,用的多的还是@RequestMapping
上述传递参数扩展性差(除了传递对象),需求一变,就需要改接口的定义。我们常说的接口指API(和interface不是一个东西),然后应用程序提供了哪些服务,接口对应到代码上,通常是指方法,注意是通常所以接口也可以指一个类。接口可以理解为客户端和服务器的一个约定,如果一端变另外一端也要变
5.传递JSON数据(重要)
传递JSON数据,下面是演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(Student student) {
return "接收到的Student:" + student.toString();
}
}
注意JSON数据要像下面这样传!输出结果如下,全是null没接收到?
使用fiddler观察,会发现请求参数在请求正文中,为什么全是null?请求中的请求参数默认是key-value型,根据key进行匹配
接收JSON对象, 需要使用 @RequestBody 注解,使得Spring MVC能够将客户端发送的请求正文中的数据自动映射到Java对象上,大白话就是使用这个注解接收请求,会将请求正文中的数据转为java对象
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestBody Student student) {
return "接收到的Student:" + student.toString();
}
}
注意:客户端的请求参数必须放在请求正文中,而不是URL或请求头中,@RequestBody注解通常用于方法参数上,而不是方法本身,所以你可以将请求正文中的数据绑定到方法参数上,而不是整个方法
6.获取URL中的参数
此时我们在 @RequestMapping("m3/{参数名}"),应该这样写,一对花括号+参数名
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3/{name}")
public String m3(@PathVariable String name) {
return "接收到的名字:" + name;
}
}
可以获取URL中的多个参数,注意获取多个参数的时候,请求格式必须和后端定义的URL格式匹配,例如后端定义的URL格式有两个参数,你请求中也得带两个参数不能只带一个参数不然会报错
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3/{id}/{name}")
public String m3(@PathVariable Integer id, @PathVariable String name) {
return "接收到的id:" + id + " " + "接收到的名字:" + name;
}
}
可以重命名,但是括号里的参数要一致!!!
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3/{id}/{name}")
public String m3(@PathVariable(value = "id") Integer MyId, @PathVariable(value = "name") String UserName) {
return "接收到的id:" + MyId + " " + "接收到的名字:" + UserName;
}
}
7.上传文件
使用postman可以上传文件,记得键也要写参数!
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestPart MultipartFile file) {
return "接收到的文件名:" + file.getOriginalFilename();
}
}
搭配file.transferTo方法可以将文件上传到指定路径file.transferTo(new File("E:/Nan/" + file.getOriginalFilename()))。这里是写死的固定的,注意要带上文件名
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m3")
public String m3(@RequestPart MultipartFile file) throws IOException {
file.transferTo(new File("E:/" + file.getOriginalFilename()));
return "success";
}
}
8.Cookie和Session
为什么要有Cookie和Session?
"无状态" 的含义指的是:默认情况下 HTTP 协议的客户端和服务器之间的这次通信, 和下次通信之间没有直接的联系,可以理解为渣男的意思玩一次就不认识了
Cookie
Session
Session的本质就是⼀个 "哈希表", 存储了⼀些键值对结构,Key 就是SessionID,Value 就是用户信息,SessionID是由服务器生成的一个"唯一性字符串",Session 机制的⻆度来看这个"唯一性字符串"可以称为SessionID,但是站在整个登录流程中看,这个"唯一性字符串"称为"token"
流程:
1. 当用户发起登陆请求把账号密码传到服务器,服务器验证成功后,在 Session 中新增⼀个新记录(存入当前用户信息),并把 sessionId返回给客户端(通过HTTP 响应中(响应头)的 Set-Cookie 字段返回),然后客户端会自动在cookie当中存入当前的sessionId
2. 客户端后续再给服务器发送请求的时候,会在请求中(请求头)自动带 sessionId(通过 HTTP 请求中的Cookie 字段带上)
3. 服务器收到请求之后,根据请求中的 sessionId在 Session 信息中获取到对应的用户信息,再进行后续操作,找不到则重新创建Session,并把SessionID返回
Cookie和Session的区别
1.Cookie是客户端保存用户信息的一种机制,Session是服务器端保存用户信息的一种的机制,所以Cookie安全性不如Session,因为Cookie存储在客户端用户可以进行篡改。通常情况两者会搭配使用,但不是必须
2.Cookie 和 Session之间主要是通过 SessionId 关联起来的,SessionId 是 Cookie 和 Session 之间的桥梁
3.Session容量比Cookie 大,所以比较占用服务器内存资源,需要依赖cookie,并且分布式以及前后端分离的架构下会有跨域问题,比如有两台服务器用户发起一个登录请求到随机一个服务器,此时这台服务器创建了session保存了用户信息,然后用户又发起一个请求到了另外一个服务器,可是这个服务器没有保存刚才用户的信息
9.获取Cookie
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m5")
public Boolean m5(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
Arrays.stream(cookies).forEach(cookie -> System.out.println(cookie.getName() + cookie.getValue()));
return true;
}
return false;
}
}
伪造Cookie
输出结果
通过注解方式获取Cookie
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m6")
public String m6(@CookieValue String kunkun) {
return "获取到的cookie:" + kunkun;
}
}
获取多个cookie
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m6")
public String m6(@CookieValue String kunkun, @CookieValue String qiange) {
return "获取到的cookie1:" + kunkun + " " + "获取到的cookie2:" + qiange;
}
}
10.获取Session
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m7")
public Boolean m7(HttpServletRequest request) {
HttpSession session = request.getSession();
if (session != null) {
System.out.println(session);
return true;
}
return false;
}
}
接下来我们将getSession方法默认值改为false
@RequestMapping("/m7")
public String m7(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
String userName = (String) request.getAttribute("userName");
return "登录用户:" + userName;
}
return "session为空";
}
我们手动设置一下session,客户端发起请求后,服务器返回的响应中带有Set-Cookie字段
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/setSession")
public String setSession(HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("userName", "坤坤");
return "success";
}
}
我们再去获取一下,看看效果
设置后再次发起请求会自动带上cookie(包含sessionid),我们通过fiddle观察
通过注解方式获取Session
通过@SessionAttribute注解获取Session,此时这个参数是必传参数,和@RequestParam一样,不传就会报错。如果没有session会报错,因为是必传参数,以及这个参数名和设置session.setAttribute中的名要一样,下图中的userName,下面进行演示
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m9")
public String m9(@SessionAttribute("userName") String userName) {
return "登录用户:" + userName;
}
}
我们设置session后再去获取,看看效果
@RequestMapping("/setSession")
public String setSession(HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("userName", "坤坤");
return "success";
}
我们也可以设置不是必传参数把required改成false即可,设置后就不是必传参数了,即使没有session也不会报错,下面看看效果
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m9")
public String m9(@SessionAttribute(value = "userName", required = false) String userName) {
return "登录用户:" + userName;
}
}
我们还可以通过Spring MVC内置对象HttpSession 来获取Session,下面这段代码等同于HttpSession session = request.getSession();注意先设置session再获取,先演示不设置的效果
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m10")
public String m10(HttpSession session) {
String userName = (String) session.getAttribute("userName");
return "登录坤户:" + userName;
}
}
效果和HttpSession session = request.getSession();一样,如果没有Session会给你设置创建一个Session,接着演示我们手动设置session再获取(写一个方法)
@RequestMapping("/setSession")
public String setSession(HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("userName", "坤坤");
return "success";
}
11.获取Header
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m11")
public String m11(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
return "获取到的User-Agent:" + userAgent;
}
}
效果如下
还可以通过注解@RequestHeader("输入你要获取Header中的哪个属性")获取Header中的属性,注意括号里的值要和HTTP请求中的值对应!
@RequestMapping("/method")
@RestController
public class DemoController {
@RequestMapping("/m11")
public String m11(@RequestHeader("User-Agent") String userAgent) {
return "通过注解获取到的User-Agent:" + userAgent;
}
}
效果如下
3.响应
1.返回静态页面
如果我们使用@RestController注解去获取静态页面的时候会出现如下图的效果
@RequestMapping("/return")
@RestController
public class Demo2Controller {
@RequestMapping("/login")
public String login() {
return "/index.html";
}
}
返回的内容是一个字符串
我们试试把@RestController注解换成@Controller注解
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@RequestMapping("/login")
public String login() {
return "/index.html";
}
}
效果如下,会返回一个我们预取的内容,也就是一个静态页面
@RestController 和 @Controller 有着什么样的关联和区别呢?
看看@RestController 注解的源码中前三个注解称为元注解,简单理解为可以被其他注解使用的注解叫元注解。({ElementType.TYPE})TYPE表示这个注解可以修饰类,具体修饰什么由@Target决定。@Retention(RetentionPolicy.RUNTIME)表示注解的生命周期。其中@RestController 注解包含@Controller注解,@Controller注解大白话就是告诉Spring帮我们管理代码,后续我们访问时才能访问到,就类似报补习班后老师对你负责一样加上这个注解就代表你报名了。准确来说是返回视图,随着开发流行"前后端分离"模式,所以MVC的概念也逐渐发⽣了变化,View不再返回视图,⽽是返回显示视图时需要的数据(也就是前端所需要的数据),所以我们可以通过@ResponseBody注解 修饰类或方法返回数据
返回一个静态页面,下面这两个代码作用一模一样,@RestController就包含@Controller和@ResponseBody。并且当存在多个注解,注解的顺序谁写谁写后没有什么关系,这里返回的是数据,不是视图,除非去掉@ResponseBody或只使用@Controller
@RequestMapping("/return")
@RestController
public class Demo2Controller {
@RequestMapping("/login")
public String login() {
return "/index.html";
}
}
@RequestMapping("/return")
@Controller
@ResponseBody
public class Demo2Controller {
@RequestMapping("/login")
public String login() {
return "/index.html";
}
}
总结:所以如果只需要返回视图就使用@Controller 注解即可,如果使用@RestController 注解的话修饰类的话,这个类中所有的方法全部返回数据
补充:看看@RequestMapping注解的源码中可以看到@Target({ElementType.TYPE, ElementType.METHOD})表示这个注解可以修饰类也可以修饰接口也可修饰方法
2.返回数据
返回数据,如果没有这个@ResponseBody注解,使用@Controller注解就会报404的错误表示资源找不到
@RequestMapping("/method")
@Controller
public class Demo2Controller {
@RequestMapping("/m1")
public String m1() {
return "视图所需的数据";
}
}
正确的应该加上@ResponseBody注解,返回才是数据
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m1")
public String m1() {
return "视图所需的数据";
}
}
3.返回html代码片段
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/returnHtml")
public String returnHtml() {
return "<h1>返回html代码片段</h1>";
}
}
4.返回JSON
观察下面的返回类型,首先返回String类型
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m2")
public String m2() {
return "success";
}
}
返回一个对象,看看效果,会自动转为JSON数据格式
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m3")
public Student m3() {
Student student = new Student();
student.setId(1);
student.setName("kun");
student.setSexy("男");
return student;
}
}
返回一个Map,看看效果,也会自动转为JSON数据格式
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m5")
public Map<String, String> m5() {
HashMap<String, String> map = new HashMap<>();
map.put("1", "kun");
map.put("2", "ji");
return map;
}
}
5.设置状态码
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m6")
public String m6(HttpServletResponse response) {
response.setStatus(401);
return "设置状态码";
}
}
虽然我们把状态码设置成了401,但是状态码不影响页面的展示,返回的数据还是照常返回根据你设置的返回,注意这里是HTTP状态码不是业务状态码,因为我们使用的这个HttpServletResponse接口是servlet提供的,所以设置是HTTP状态码。如果想设置业务状态码,我们是在业务类进行设置的。即使我们设置了401它还是返回数据了,也可以使用fiddler观察
6.设置Header
@RequestMapping源码解释
补充:Consumes就是你发送过来请求的数据类型得和我这边指定的一样才会处理,produces就是设置返回的数据格式
没指定返回的格式类型,就像下面这样
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping("/m2")
public String m2() {
return "success";
}
}
设置Content-Type(指定返回的格式类型)
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping(value = "/m7", produces = "application/json;charset=utf-8")
public String m7() {
return "设置Content-Type";
}
}
设置其他Header,效果如下在我们响应中会多了一个字段
@RequestMapping("/return")
@Controller
public class Demo2Controller {
@ResponseBody
@RequestMapping(value = "/m7")
public String m7(HttpServletResponse response) {
response.setHeader("MyHeader", "MyHeader");
return "设置Header";
}
}
开发中程序报错,如何定位问题?
1.通过打印日志,通常写在方法的第一行方便观察,如果没输出说明请求都没有到后端,这个方法前后端都通用
2.测试接口,如果没问题那问题就是出在前端了,下图是测试接口
3.如果代码改了没生效,可能是缓存的原因,清除一下,前后端都清除一下
lombok工具包
先引入依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
@Data
public class Student {
private Integer id;
private String name;
private String sexy;
}
如果有些属性不想 被获取到或被设置,就对想被获取到的属性加上@Getter注解/@Setter注解即可,不想要的不添加即可,颜色就是灰色的
public class Student {
@Setter
@Getter
private Integer id;
@Getter
@Setter
private String name;
private String sexy;
}
Java程序运行原理
lombok在其中的作用
以下是lombok提供的注解
@Data注解包含了以下很多注解,@Data = @Getter + @Setter + @ToString + @EqualsAndHashCode +@RequiredArgsConstructor+@NoArgsConstructor
应⽤分层
三层架构
1.表现层:接收请求,返回结果
2.业务逻辑层:主要处理业务逻辑,对业务的具体实现
3.数据层:处理和存储和管理与应⽤程序相关的数据
上述三个部分在Spring中的实现,都有体现有对应的
Controller:控制层,接收前端发来的请求,并且验证参数是否合法,然后响应处理后的结果
Service:业务逻辑层,处理具体的业务逻辑,真正干活的部分
Dao:数据访问层也称持久层,负责数据访问操作,包括数据的增、删、改、查
MVC 和三层架构的区别和联系
有人认为三层架构是MVC模式的⼀种实现,也有⼈认为MVC是三层架构的替代⽅案,根据自己的理解去说即可,从概念上看两者都是软件⼯程领域中的架构模式。MVC中,视图(View)和控制器(Controller)合起来对应三层架构中的表现层,模型(Model)对应三层架构中的业务逻辑层,数据层,以及实体类
MVC强调的是数据和视图分离,将数据展示和数据处理分开,通过控制器对两者进行组合。三层架构强调不同维度数据处理的高内聚和低耦合。将交互界面,业务处理和数据库操作的逻辑分开
两者的目的是相同的,都是"解耦,分层,代码复⽤"。并且在项目中两者也可以结合起来使用,最终的目的都是一样的,取决于你怎么想的怎么看的
软件设计原则
注解总结:
1. @RequestMapping: 路由映射
2. @RequestParam: 后端参数重命名
3. @RequestBody: 接收JSON类型的参数
4. @PathVariable: 接收路径参数
5. @RequestPart: 上传⽂件
6. @ResponseBody: 返回数据
7. @CookieValue: 从Cookie中获取值
8. @SessionAttribute: 从Session中获取值
9. @RequestHeader: 从Header中获取值
10. @Controller: 定义⼀个控制器, Spring 框架启动时加载, 把这个对象交给Spring管理. 默认返回
视图
11. @RestController: @ResponseBody + @Controller 返回数据
Spring全家桶总结
Spring
SpringBoot
SpringWeb
Spring MVC
@RequestMapping注解就是Spring MVC的体现,也就是用于web功能,所以说Spring MVC是一个Web框架
总结:Spring是一个大型框架为了更快的写java程序;Spring Boot是为了简化Spring应用开发而设计的工具为了更快的写Spring程序;Spring MVC和Spring Web(MVC)则是专注于Web应用的模块和功能
以上便是本章Spring MVC的所有内容,知识点多且重要,好好消化,我们下一章再见💕