STOMP로 채팅 만들기
2022. 2. 25. 02:17ㆍ스프링부트
구현 방식
메인화면에 접속하면 웹소켓을 연결하고 /roomList를 구독합니다
사용자가 방을 만들면 /roomList로 메세지를 보내 새로운 방이 만들어졌다는 걸 알립니다
메인화면에 있는 사용자들은 /roomList로 메세지를 받으면 방목록을 불러옵니다
방을 만들거나 다른사람이 만든 방에 들어갈 경우 /roomList의 구독을 취소하고 /message/방번호(UUID)를 구독합니다
방 안에서 같은 방번호를 구독한 사람끼리 메세지를 주고받을 수 있습니다
스프링부트와 jsp를 사용했고 아래는 사용 라이브러리입니다
여기에 jsp만 추가해서 사용했습니다
<!-- JSP 템플릿 엔진 -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
</dependency>
application.properties에 viewresolver를 설정합니다
spring.mvc.view.prefix=/WEB-INF/view/
spring.mvc.view.suffix=.jsp
프로젝트 구조
웹소켓 설정
WebSocketConfig.java
@EnableWebSocketMessageBroker
@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
}
}
Message.java
@Data
public class Message {
private String message;
private String nickname;
private Date date;
Message(){
date = new Date();
}
}
ChatingRoom.java
@Data
@Builder
public class ChatingRoom {
private String roomNumber;
private String roomName;
private LinkedList<String> users;
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
ChatingRoom other = (ChatingRoom) obj;
return Objects.equals(roomNumber, other.roomNumber);
}
@Override
public int hashCode() {
return Objects.hash(roomNumber);
}
}
채팅방에는 방번호, 방이름, 유저목록이 있는데 채팅방 목록에서 방번호로 방을 찾을 수 있게 equals와 hashCode를 오버라이딩 했고 생성자를 사용하지 않고 Builder패턴을 사용했습니다
메세지처리를 할 컨트롤러와 각종 유틸메서드는 클래스를 추가해 따로 관리해야 하지만 분리하지 않고 MainContoller 한곳에 모아놨습니다
MainController.java
@Controller
public class MainController {
// 채팅방 목록
public static LinkedList<ChatingRoom> chatingRoomList = new LinkedList<>();
// ----------------------------------------------------
// 유틸 메서드
// 방 번호로 방 찾기
public ChatingRoom findRoom(String roomNumber) {
ChatingRoom room = ChatingRoom.builder().roomNumber(roomNumber).build();
int index = chatingRoomList.indexOf(room);
if(chatingRoomList.contains(room)) {
return chatingRoomList.get(index);
}
return null;
}
// 쿠키에 추가
public void addCookie(String cookieName, String cookieValue) {
ServletRequestAttributes attr = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletResponse response = attr.getResponse();
Cookie cookie = new Cookie(cookieName, cookieValue);
int maxage = 60 * 60 * 24 * 7;
cookie.setMaxAge(maxage);
response.addCookie(cookie);
}
// 방 번호, 닉네임 쿠키 삭제
public void deleteCookie( ) {
ServletRequestAttributes attr = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletResponse response = attr.getResponse();
Cookie roomCookie = new Cookie("roomNumber", null);
Cookie nicknameCookie = new Cookie("nickname",null);
roomCookie.setMaxAge(0);
nicknameCookie.setMaxAge(0);
response.addCookie(nicknameCookie);
response.addCookie(roomCookie);
}
// 쿠키에서 방번호, 닉네임 찾기
public Map<String, String> findCookie() {
ServletRequestAttributes attr = (ServletRequestAttributes)RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = attr.getRequest();
Cookie[] cookies = request.getCookies();
String roomNumber = "";
String nickname= "";
if(cookies == null) {
return null;
}
if(cookies != null) {
for(int i=0;i<cookies.length;i++) {
if("roomNumber".equals(cookies[i].getName())) {
roomNumber = cookies[i].getValue();
}
if("nickname".equals(cookies[i].getName())) {
nickname = cookies[i].getValue();
}
}
if(!"".equals(roomNumber) && !"".equals(nickname)) {
Map<String, String> map = new HashMap<>();
map.put("nickname", nickname);
map.put("roomNumber", roomNumber);
return map;
}
}
return null;
}
// 닉네임 생성
public void createNickname(String nickname) {
addCookie("nickname", nickname);
}
// 방 입장하기
public boolean enterChatingRoom(ChatingRoom chatingRoom, String nickname) {
createNickname(nickname);
if(chatingRoom == null) {
deleteCookie();
return false;
} else {
LinkedList<String> users = chatingRoom.getUsers();
users.add(nickname);
addCookie("roomNumber", chatingRoom.getRoomNumber());
return true;
}
}
// ----------------------------------------------------
// 컨트롤러
// 메인화면
@GetMapping("/")
public String main() {
return "main";
}
// 채팅방 목록
@GetMapping("/chatingRoomList")
public ResponseEntity<?> chatingRoomList() {
return new ResponseEntity<LinkedList<ChatingRoom>>(chatingRoomList, HttpStatus.OK);
}
// 방 만들기
@PostMapping("/chatingRoom")
public ResponseEntity<?> chatingRoom(String roomName, String nickname) {
// 방을 만들고 채팅방목록에 추가
String roomNumber = UUID.randomUUID().toString();
ChatingRoom chatingRoom = ChatingRoom.builder()
.roomNumber(roomNumber)
.users(new LinkedList<>())
.roomName(roomName)
.build();
chatingRoomList.add(chatingRoom);
// 방 입장하기
enterChatingRoom(chatingRoom, nickname);
return new ResponseEntity<>(chatingRoom, HttpStatus.OK);
}
// 방 들어가기
@GetMapping("/chatingRoom-enter")
public ResponseEntity<?> EnterChatingRoom(String roomNumber, String nickname){
// 방 번호로 방 찾기
ChatingRoom chatingRoom = findRoom(roomNumber);
if(chatingRoom == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} else {
// 방 들어가기
enterChatingRoom(chatingRoom, nickname);
return new ResponseEntity<>(chatingRoom, HttpStatus.OK);
}
}
// 방 나가기
@PatchMapping("/chatingRoom-exit")
public ResponseEntity<?> ExitChatingRoom(){
Map<String, String> map = findCookie();
if(map == null) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
String roomNumber = map.get("roomNumber");
String nickname = map.get("nickname");
// 방목록에서 방번호에 맞는 유저목록 가져오기
ChatingRoom chatingRoom = findRoom(roomNumber);
List<String> users = chatingRoom.getUsers();
// 닉네임 삭제
users.remove(nickname);
// 쿠키에서 닉네임과 방번호 삭제
deleteCookie();
// 유저가 한명도 없다면 방 삭제
if(users.size() == 0) {
chatingRoomList.remove(chatingRoom);
}
return new ResponseEntity<>(chatingRoom, HttpStatus.OK);
}
// 참가 중이었던 대화방
@GetMapping("/chatingRoom")
public ResponseEntity<?> chatingRoom() {
// 쿠키에 닉네임과 방번호가 있다면 대화중이던 방이 있던것
Map<String, String> map = findCookie();
if(map == null) {
return new ResponseEntity<>(HttpStatus.OK);
}
String roomNumber = map.get("roomNumber");
String nickname = map.get("nickname");
ChatingRoom chatingRoom = findRoom(roomNumber);
if(chatingRoom == null) {
deleteCookie();
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} else {
Map<String, Object> map2 = new HashMap<>();
map2.put("chatingRoom", chatingRoom);
map2.put("myNickname", nickname);
return new ResponseEntity<>(map2, HttpStatus.OK);
}
}
// ----------------------------------------------------
// 메세지 컨트롤러
// 여기서 메세지가 오면 방목록 업데이트
@MessageMapping("/socket/roomList")
@SendTo("/topic/roomList")
public String roomList() {
return "";
}
// 채팅방에서 메세지 보내기
@MessageMapping("/socket/sendMessage/{roomNumber}")
@SendTo("/topic/message/{roomNumber}")
public Message sendMessage(@DestinationVariable String roomNumber, Message message) {
return message;
}
// 채팅방에 입장 퇴장 메세지 보내기
@MessageMapping("/socket/notification/{roomNumber}")
@SendTo("/topic/notification/{roomNumber}")
public Map<String, Object> notification(@DestinationVariable String roomNumber, Map<String, Object> chatingRoom) {
return chatingRoom;
}
}
main.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>채팅</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<!-- 채팅방 목록 -->
<main>
<h1>채팅방</h1>
<button class="new_chat">새 채팅방 만들기</button>
<nav>
<span>방 제목</span>
<span>인원</span>
</nav>
<hr>
<ul>
<li>
<!--
<span class="chat_title"></span>
<span class="chat_count"></span>
-->
</li>
</ul>
</main>
<!-- 채팅방 목록 -->
<!-- 채팅방 입장 -->
<div class="chat">
<div>
<div class="chat_body">
<h2 class="chat_title">1번방</h2>
<button class="chat_back">◀</button>
<ul class="chat_list">
<li>
<!-- <div class="notification">
<span></span>
</div> -->
</li>
</ul>
<div class="chat_input">
<div class="chat_input_area">
<textarea></textarea>
</div>
<div class="chat_button_area">
<button>전송</button>
</div>
</div>
</div>
<div class="chat_users">
<h2>
참가인원
<span class="user"></span>
</h2>
<div class="chat_nickname">
<ul>
<li>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 채팅방 입장 -->
<!-- sock js -->
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.5.2/sockjs.min.js"></script>
<!-- STOMP -->
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://unpkg.com/sweetalert/dist/sweetalert.min.js"></script>
<script type="text/javascript" src="http://code.jquery.com/jquery-latest.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>
main.css
@charset "UTF-8";
* {
margin: 0;
padding: 0;
}
li {
list-style: none;
}
html {
font-size: 62.5%;
}
.swal-footer {
text-align: center;
}
/* 채팅방 목록 */
main {
width: 35vw;
min-width: 300px;
height: 90vh;
border: 1px solid #000;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
main h1 {
font-size: 2rem;
padding: 10px;
height: 50px;
box-sizing: border-box;
}
main .new_chat {
position: absolute;
top: 10px;
right: 10px;
border-radius: 3px;
padding: 3px;
background: #fff;
cursor: pointer;
}
main .new_chat:hover {
background: #eee;
}
main nav {
font-size: 1.6rem;
display: flex;
text-align: center;
margin-bottom: 5px;
padding: 0 7px;
}
main nav > span:first-child {
width: 80%;
}
main nav > span:last-child {
width: 20%;
}
main ul {
overflow-y: auto;
max-height: calc(100% - 50px);
}
main li {
font-size: 1.6rem;
width: 100%;
height: 70px;
padding: 7px;
box-sizing: border-box;
display: flex;
justify-content: space-between;
}
main li:hover {
background: #eee;
}
main .chat_title {
font-weight: bold;
width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
main .chat_count {
width: 20%;
text-align: center;
}
/* 채팅방 목록 */
/* 채팅방 안 */
.chat {
width: 50vw;
min-width: 300px;
height: 90vh;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
display: none;
}
.chat > div {
height: 100%;
display: flex;
}
.chat .chat_body {
background: rgb(195, 204, 221);
height: 100%;
width: 70%;
order: 1;
border: 1px solid #000;
position: relative;
}
.chat h2 {
font-size: 2rem;
padding: 10px;
height: 50px;
box-sizing: border-box;
text-align: center;
background: #eee;
}
.chat .chat_back {
font-size: 2rem;
width: 50px;
height: 50px;
position: absolute;
top: 0;
left: 0;
border: none;
cursor: pointer;
}
.chat ul.chat_list {
overflow-y: auto;
height: calc(100% - 150px);
box-sizing: border-box;
padding: 5px 0;
}
.chat li {
font-size: 1.5rem;
}
.chat li > div {
padding: 10px;
display: flex;
}
.chat li .notification {
text-align: center;
}
.chat li .notification span {
margin: 0 auto;
border-radius: 15px;
padding: 5px 10px;
background: #ae9191;
color: #fff;
}
.chat li > div .nickname {
padding: 3px;
}
.chat li > div .message {
display: flex;
}
.chat .chat_me {
justify-content: end;
}
.chat .chat_other {
justify-content: start;
}
.chat .chat_other .chat_in_time {
order: 1;
}
.chat .chat_in_time {
font-size: 1.3rem;
margin: 0 5px;
display: flex;
align-items: flex-end;
}
.chat .chat_content {
padding: 5px;
border-radius: 3px;
box-shadow: 0px 2px 3px 0px rgb(0 0 0 / 25%);
display: inline-block;
max-width: 250px;
max-height: 400px;
overflow-y: auto;
word-break: break-all;
}
.chat .chat_me .chat_content {
background: yellow;
}
.chat .chat_other .chat_content {
background: #fff;
}
.chat .chat_input {
height: 100px;
display: flex;
background: #fff;
}
.chat_input .chat_input_area {
width: 87%;
}
.chat_input .chat_input_area textarea {
width: 100%;
height: 100%;
border: none;
resize: none;
padding: 8px;
box-sizing: border-box;
}
.chat_input .chat_input_area textarea:focus {
outline: none;
}
.chat_input .chat_button_area {
width: 13%;
}
.chat_input .chat_button_area button {
background: yellow;
border: 1px solid #ddd;
border-radius: 3px;
padding: 5px;
width: 90%;
margin-top: 10px;
cursor: pointer;
}
.chat .chat_users {
border: 1px solid #ddd;
width: 30%;
height: 300px;
/* position: absolute;
right: 101%;
top: 0; */
margin-right: 3px;
}
/* 참가인원 */
.chat .chat_users h2 {
font-size: 1.6rem;
}
.chat .chat_users .chat_nickname {
font-size: 1.6rem;
height: calc(100% - 50px);
overflow: auto;
}
.chat .chat_users .chat_nickname li {
padding: 5px;
}
/* 참가인원 */
/* 채팅방 안 */
@media(max-width: 1024px) {
html {
font-size: 60%;
}
main {
width: 99vw;
}
.chat {
width: 99vw;
}
}
main.js
$(document).ready(function(){
// 방 목록 그리기
const listHtml = function(roomList) {
let listHtml = "";
for(let i=roomList.length-1;i>=0;i--) {
listHtml += `
<li data-room_number=${roomList[i].roomNumber}>
<span class="chat_title">${roomList[i].roomName }</span>
<span class="chat_count">${roomList[i].users.length}명</span>
</li>`;
}
$("main ul").html(listHtml);
}
// 채팅방 목록 불러오기
const chatingRoomList = function(){
$.ajax({
url: "/chatingRoomList",
type: "GET",
})
.then(function(result){
listHtml(result)
})
.fail(function(){
alert("에러가 발생했습니다");
})
}
const socket = new SockJS('/websocket');
const stomp = Stomp.over(socket);
stomp.debug = null; // stomp 콘솔출력 X
// 구독을 취소하기위해 구독 시 아이디 저장
const subscribe = [];
// 모든 구독 취소하기
const subscribeCancle = function() {
const length = subscribe.length;
for(let i=0;i<length;i++) {
const sid = subscribe.pop();
stomp.unsubscribe(sid.id);
}
}
// 메인 화면
const main = function() {
$("main").show();
// 기존 구독 취소
subscribeCancle();
// 채팅 중이었던 방이 있을때
const room = chatingRoom();
if(room) {
return;
}
const subscribeId = stomp.subscribe("/topic/roomList", function(){
// "/topic/roomList"에서 메세지가 왔을때 실행할 함수
chatingRoomList();
});
subscribe.push(subscribeId);
chatingRoomList();
};
stomp.connect({}, function(){
main();
});
// ----------------- 메인화면 ---------------------------
// ----------------- 채팅방 ---------------------------
const info = (function(){
let nickname = "";
let roomNumber = "";
const getNickname = function() {
return nickname;
}
const setNickname = function(set){
nickname = set;
}
const getRoomNumber = function() {
return roomNumber;
}
const setRoomNumber = function(set) {
roomNumber = set;
}
return {
getNickname : getNickname,
setNickname : setNickname,
getRoomNumber : getRoomNumber,
setRoomNumber : setRoomNumber,
}
})();
const errorMSG = function(result){
if(result.status == 404) {
alert("종료되었거나 없는 방입니다");
} else {
alert("에러가 발생했습니다");
}
location.href = "/";
}
// 참가자 그리기
const userList = function(users){
$(".chat .chat_users .user").text(users.length + "명");
let userHtml = "";
for(let i=0;i<users.length;i++) {
userHtml += `
<li>${users[i] }</li>`;
}
$(".chat .chat_nickname ul").html(userHtml);
}
// 메세지 그리기
const chating = function(messageInfo){
let nickname = messageInfo.nickname;
let message = messageInfo.message;
message = message.replaceAll("\n", "<br>").replaceAll(" ", " ");
const date = messageInfo.date;
const d = new Date(date);
const time = String(d.getHours()).padStart(2, "0")
+ ":"
+ String(d.getMinutes()).padStart(2, "0");
let sender = "";
if(info.getNickname() == nickname) {
sender = "chat_me";
nickname = "";
} else {
sender= "chat_other";
}
const chatHtml = `
<li>
<div class=${sender }>
<div>
<div class="nickname">${nickname }</div>
<div class="message">
<span class=chat_in_time>${time }</span>
<span class="chat_content">${message }</span>
<span>
</div>
</div>
</li>`;
$(".chat ul.chat_list").append(chatHtml);
$(".chat ul").scrollTop($(".chat ul")[0].scrollHeight);
}
// 채팅방 구독
const chatingConnect = function(roomNumber){
// 기존 구독 취소
subscribeCancle();
// 메세지를 받을 경로
const id1 = stomp.subscribe("/topic/message/" + roomNumber, function(result){
const message = JSON.parse(result.body);
// 메세지가 왔을때 실행할 함수
chating(message);
})
// 입장,퇴장 알림을 받을 경로
const id2 = stomp.subscribe("/topic/notification/" + roomNumber, function(result){
const room = JSON.parse(result.body);
const message = room.message;
// 메세지가 왔을때 실행할 함수
userList(room.users);
const chatHtml = `
<li>
<div class="notification">
<span>${message}</span>
</div>
</li>`;
$(".chat ul.chat_list").append(chatHtml);
$(".chat ul").scrollTop($(".chat ul")[0].scrollHeight);
})
subscribe.push(id1);
subscribe.push(id2);
}
// 채팅방 세팅
const initRoom = function(room, nickname) {
// 방 목록 업데이트
stomp.send("/socket/roomList");
$("main").hide();
info.setNickname(nickname);
info.setRoomNumber(room.roomNumber);
$(".chat").show();
$(".chat .chat_title").text(room.roomName);
userList(room.users);
chatingConnect(room.roomNumber);
$(".chat_input_area textarea").focus();
}
// 메세지 보내기
const sendMessage = function(){
const message = $(".chat_input_area textarea");
if (message.val() == "") {
return;
}
const roomNumber = info.getRoomNumber();
const nickname = info.getNickname();
const data = {
message : message.val(),
nickname : nickname,
}
stomp.send("/socket/sendMessage/" + roomNumber, {}, JSON.stringify(data));
message.val("");
}
$(".chat_button_area button").click(function() {
sendMessage();
$(".chat_input_area textarea").focus();
})
$(".chat_input_area textarea").keypress(function(event) {
if (event.keyCode == 13) {
if (!event.shiftKey) {
event.preventDefault();
sendMessage();
}
}
})
// 닉네임 만들고 채팅방 들어가기
const enterChatingRoom = function(roomNumber) {
swal({
text: "사용하실 닉네임을 입력해주세요",
content: "input",
buttons: ["취소", "확인"],
closeOnClickOutside : false
})
.then(function(nickname){
if(nickname) {
const data = {
roomNumber : roomNumber,
nickname : nickname
}
$.ajax({
url: "/chatingRoom-enter",
type: "GET",
data: data,
})
.then(function(room){
initRoom(room, nickname);
// 채팅방 참가 메세지
room.message = nickname + "님이 참가하셨습니다";
stomp.send(
"/socket/notification/" + roomNumber, {},
JSON.stringify(room));
})
.fail(function(result){
errorMSG(result);
})
}
})
}
// 새 채팅방 만들기
const createRoom = function(roomName) {
swal({
text: "사용하실 닉네임을 입력해주세요",
content: "input",
buttons: ["취소", "확인"],
closeOnClickOutside : false
})
.then(function(nickname){
if(nickname) {
const data = {
roomName : roomName,
nickname : nickname
}
$.ajax({
url: "/chatingRoom",
type: "POST",
data: data,
})
.then(function(room){
initRoom(room, nickname)
})
.fail(function(){
alert("에러가 발생했습니다");
})
}
})
}
$(".new_chat").click(function(){
swal({
text: "방 이름을 입력해주세요",
content: "input",
buttons: ["취소", "확인"],
closeOnClickOutside : false
})
.then(function(roomName){
if(roomName) {
createRoom(roomName);
}
})
})
$(document).on("dblclick", "main li", function(){
const roomNumber = $(this).data("room_number");
enterChatingRoom(roomNumber);
})
// 채팅방 나가기
$(".chat_back").click(function() {
swal({
text: "대화방에서 나갈까요?",
buttons: ["취소", "확인"]
})
.then(function(result){
if(result) {
$.ajax({
url: "/chatingRoom-exit",
type: "PATCH",
})
.then(function(room){
const roomNumber = info.getRoomNumber();
if(room.users.length != 0) {
// 채팅방 나가기 메세지
room.message = info.getNickname() + "님이 퇴장하셨습니다";
stomp.send(
"/socket/notification/" + roomNumber, {},
JSON.stringify(room));
}
// 채팅방 목록 업데이트
stomp.send("/socket/roomList");
main();
$(".chat").hide();
$(".chat ul.chat_list").html("");
info.setRoomNumber("");
info.setNickname("");
})
.fail(function(){
errorMSG();
})
}
})
})
// 대화 중이던 방
const chatingRoom = function (){
let returnRoom = null;
$.ajax({
url: "/chatingRoom",
type: "GET",
async: false,
})
.then(function(result){
if(result != "") {
const room = result.chatingRoom;
const nickname = result.myNickname;
initRoom(room, nickname);
returnRoom = result;
}
})
.fail(function(result){
errorMSG(result);
})
return returnRoom;
};
}) // document.ready
'스프링부트' 카테고리의 다른 글
스프링부트+jsp로 배달사이트 만들기-41 관리자가 주문취소시 환불하기 추가 (1) | 2022.01.20 |
---|---|
스프링부트+jsp로 배달사이트 만들기-40 결제api 사용해서 주문하기(아임포트) (3) | 2022.01.09 |
sts4 깃 임포트 후 db 연결 에러 (0) | 2022.01.07 |
스프링부트+jsp로 배달사이트 만들기-39 비밀번호 찾기, 비밀번호 변경 (0) | 2022.01.04 |
스프링부트+jsp로 배달사이트 만들기-38 이메일보내기(아이디 찾기,비번찾기) (0) | 2021.12.29 |