diff --git a/backend/build.gradle b/backend/build.gradle index cd70651b4..59e75b316 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -53,6 +53,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-amqp' // rabbitmq + implementation 'org.springframework.boot:spring-boot-starter-reactor-netty' // rabbitmq implementation 'io.jsonwebtoken:jjwt:0.12.6' implementation 'io.micrometer:micrometer-registry-prometheus' implementation "org.springframework.boot:spring-boot-starter-actuator" @@ -91,12 +93,12 @@ tasks.withType(GenerateSwaggerUI) { doFirst { def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml") - def securitySchemesContent = " securitySchemes:\n" + \ - " APIKey:\n" + \ - " type: apiKey\n" + \ - " name: Authorization\n" + \ - " in: header\n" + \ - "security:\n" + + def securitySchemesContent = " securitySchemes:\n" + \ + " APIKey:\n" + \ + " type: apiKey\n" + \ + " name: Authorization\n" + \ + " in: header\n" + \ + "security:\n" + " - APIKey: [] # Apply the security scheme here" swaggerUIFile.append securitySchemesContent @@ -130,5 +132,5 @@ resolveMainClassName { test { systemProperty 'user.timezone', 'Asia/Seoul' - + } diff --git a/backend/src/main/java/com/happy/friendogly/chat/config/ChatTemplate.java b/backend/src/main/java/com/happy/friendogly/chat/config/ChatTemplate.java new file mode 100644 index 000000000..f82a53b2c --- /dev/null +++ b/backend/src/main/java/com/happy/friendogly/chat/config/ChatTemplate.java @@ -0,0 +1,6 @@ +package com.happy.friendogly.chat.config; + +public interface ChatTemplate { + + void convertAndSend(Long chatRoomId, Object payload); +} diff --git a/backend/src/main/java/com/happy/friendogly/chat/config/InMemoryChatTemplate.java b/backend/src/main/java/com/happy/friendogly/chat/config/InMemoryChatTemplate.java new file mode 100644 index 000000000..b02fe0ba4 --- /dev/null +++ b/backend/src/main/java/com/happy/friendogly/chat/config/InMemoryChatTemplate.java @@ -0,0 +1,23 @@ +package com.happy.friendogly.chat.config; + +import org.springframework.context.annotation.Profile; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +@Component +@Profile("local") +public class InMemoryChatTemplate implements ChatTemplate { + + private static final String TOPIC_CHAT_PREFIX = "/exchange/chat.exchange/room."; + + private final SimpMessagingTemplate template; + + public InMemoryChatTemplate(SimpMessagingTemplate template) { + this.template = template; + } + + @Override + public void convertAndSend(Long chatRoomId, Object payload) { + template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoomId, payload); + } +} diff --git a/backend/src/main/java/com/happy/friendogly/chat/config/RabbitChatTemplate.java b/backend/src/main/java/com/happy/friendogly/chat/config/RabbitChatTemplate.java new file mode 100644 index 000000000..efe554c3f --- /dev/null +++ b/backend/src/main/java/com/happy/friendogly/chat/config/RabbitChatTemplate.java @@ -0,0 +1,21 @@ +package com.happy.friendogly.chat.config; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("!local") +public class RabbitChatTemplate implements ChatTemplate { + + private final RabbitTemplate rabbitTemplate; + + public RabbitChatTemplate(RabbitTemplate rabbitTemplate) { + this.rabbitTemplate = rabbitTemplate; + } + + @Override + public void convertAndSend(Long chatRoomId, Object payload) { + rabbitTemplate.convertAndSend("chat.exchange", "room." + chatRoomId, payload); + } +} diff --git a/backend/src/main/java/com/happy/friendogly/chat/controller/ChatSocketController.java b/backend/src/main/java/com/happy/friendogly/chat/controller/ChatSocketController.java index bf3fc35e9..2d77d692c 100644 --- a/backend/src/main/java/com/happy/friendogly/chat/controller/ChatSocketController.java +++ b/backend/src/main/java/com/happy/friendogly/chat/controller/ChatSocketController.java @@ -16,7 +16,6 @@ import org.springframework.messaging.handler.annotation.MessageExceptionHandler; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.RestController; @RestController @@ -24,40 +23,28 @@ public class ChatSocketController { private final ChatCommandService chatCommandService; private final ChatRoomQueryService chatRoomQueryService; - private final SimpMessagingTemplate template; public ChatSocketController( ChatCommandService chatCommandService, - ChatRoomQueryService chatRoomQueryService, - SimpMessagingTemplate template + ChatRoomQueryService chatRoomQueryService ) { this.chatCommandService = chatCommandService; this.chatRoomQueryService = chatRoomQueryService; - this.template = template; } - @MessageMapping("/invite") + @MessageMapping("/invite") // TODO: 1대1 채팅방 구현 시 수정 필요 public void invite( @WebSocketAuth Long senderMemberId, @Payload InviteToChatRoomRequest request ) { chatRoomQueryService.validateInvitation(senderMemberId, request); - template.convertAndSend( - "/topic/invite/" + request.receiverMemberId(), - new InviteToChatRoomResponse(request.chatRoomId()) - ); - } - - @Deprecated - @MessageMapping("/enter/{chatRoomId}") - public void enter( - @WebSocketAuth Long memberId, - @DestinationVariable(value = "chatRoomId") Long chatRoomId - ) { - chatCommandService.sendEnter(memberId, chatRoomId); +// template.convertAndSend( +// "/topic/invite/" + request.receiverMemberId(), +// new InviteToChatRoomResponse(request.chatRoomId()) +// ); } - @MessageMapping("/chat/{chatRoomId}") + @MessageMapping("chat.{chatRoomId}") public void sendMessage( @WebSocketAuth Long memberId, @DestinationVariable(value = "chatRoomId") Long chatRoomId, @@ -66,15 +53,6 @@ public void sendMessage( chatCommandService.sendChat(memberId, chatRoomId, request); } - @Deprecated - @MessageMapping("/leave/{chatRoomId}") - public void leave( - @WebSocketAuth Long memberId, - @DestinationVariable(value = "chatRoomId") Long chatRoomId - ) { - chatCommandService.sendLeave(memberId, chatRoomId); - } - @MessageExceptionHandler public ResponseEntity> handleException(FriendoglyException exception) { ErrorResponse errorResponse = new ErrorResponse( diff --git a/backend/src/main/java/com/happy/friendogly/chat/service/ChatCommandService.java b/backend/src/main/java/com/happy/friendogly/chat/service/ChatCommandService.java index 5f8bbf6c5..37a6369b8 100644 --- a/backend/src/main/java/com/happy/friendogly/chat/service/ChatCommandService.java +++ b/backend/src/main/java/com/happy/friendogly/chat/service/ChatCommandService.java @@ -4,6 +4,7 @@ import static com.happy.friendogly.chat.domain.MessageType.ENTER; import static com.happy.friendogly.chat.domain.MessageType.LEAVE; +import com.happy.friendogly.chat.config.ChatTemplate; import com.happy.friendogly.chat.domain.ChatMessage; import com.happy.friendogly.chat.domain.ChatRoom; import com.happy.friendogly.chat.domain.MessageType; @@ -16,7 +17,6 @@ import com.happy.friendogly.member.repository.MemberRepository; import com.happy.friendogly.notification.service.NotificationService; import java.time.LocalDateTime; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,27 +24,26 @@ @Transactional public class ChatCommandService { - private static final String TOPIC_CHAT_PREFIX = "/topic/chat/"; private static final String EMPTY_CONTENT = ""; private final MemberRepository memberRepository; private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; private final NotificationService notificationService; - private final SimpMessagingTemplate template; + private final ChatTemplate chatTemplate; public ChatCommandService( MemberRepository memberRepository, ChatRoomRepository chatRoomRepository, ChatMessageRepository chatMessageRepository, NotificationService notificationService, - SimpMessagingTemplate template + ChatTemplate chatTemplate ) { this.memberRepository = memberRepository; this.chatRoomRepository = chatRoomRepository; this.chatMessageRepository = chatMessageRepository; this.notificationService = notificationService; - this.template = template; + this.chatTemplate = chatTemplate; } public void sendEnter(Long senderMemberId, Long chatRoomId) { @@ -71,9 +70,10 @@ public void sendLeave(Long senderMemberId, Long chatRoomId) { } private void sendAndSave(MessageType messageType, String content, ChatRoom chatRoom, Member senderMember) { - ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember, LocalDateTime.now()); + ChatMessageSocketResponse chat = new ChatMessageSocketResponse(messageType, content, senderMember, + LocalDateTime.now()); notificationService.sendChatNotification(chatRoom.getId(), chat); - template.convertAndSend(TOPIC_CHAT_PREFIX + chatRoom.getId(), chat); + chatTemplate.convertAndSend(chatRoom.getId(), chat); chatMessageRepository.save(new ChatMessage(chatRoom, messageType, senderMember, content)); } } diff --git a/backend/src/main/java/com/happy/friendogly/config/RabbitMqConfig.java b/backend/src/main/java/com/happy/friendogly/config/RabbitMqConfig.java new file mode 100644 index 000000000..e4d52fb03 --- /dev/null +++ b/backend/src/main/java/com/happy/friendogly/config/RabbitMqConfig.java @@ -0,0 +1,108 @@ +package com.happy.friendogly.config; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.rabbitmq.client.ConnectionFactory; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import org.springframework.amqp.core.AmqpAdmin; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.connection.CachingConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitAdmin; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@EnableRabbit +@Profile("!local") +public class RabbitMqConfig { + + private static final String CHAT_QUEUE_NAME = "chat.queue"; + private static final String CHAT_EXCHANGE_NAME = "chat.exchange"; + private static final String ROUTING_KEY = "*.room.*"; + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + @Value("${spring.rabbitmq.host}") + private String host; + + @Value("${spring.rabbitmq.username}") + private String username; + + @Value("${spring.rabbitmq.password}") + private String password; + + @Value("${spring.rabbitmq.stomp-port}") + private int port; + + @Bean + public Queue queue() { + return new Queue(CHAT_QUEUE_NAME, true); + } + + @Bean + public TopicExchange topicExchange() { + return new TopicExchange(CHAT_EXCHANGE_NAME, true, false); + } + + @Bean + public Binding binding(Queue queue, TopicExchange topicExchange) { + return BindingBuilder.bind(queue) + .to(topicExchange) + .with(ROUTING_KEY); + } + + @Bean + public ConnectionFactory connectionFactory() { + CachingConnectionFactory connectionFactory = new CachingConnectionFactory(); + connectionFactory.setHost(host); + connectionFactory.setPort(port); + connectionFactory.setUsername(username); + connectionFactory.setPassword(password); + connectionFactory.setVirtualHost("/"); + return connectionFactory.getRabbitConnectionFactory(); + } + + @Bean + public AmqpAdmin amqpAdmin(RabbitTemplate rabbitTemplate) { + RabbitAdmin rabbitAdmin = new RabbitAdmin(rabbitTemplate.getConnectionFactory()); + rabbitAdmin.declareExchange(topicExchange()); + return rabbitAdmin; + } + + @Bean + public RabbitTemplate rabbitTemplate( + org.springframework.amqp.rabbit.connection.ConnectionFactory connectionFactory) { + + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(jackson2JsonMessageConverter()); + return rabbitTemplate; + } + + @Bean + public Jackson2JsonMessageConverter jackson2JsonMessageConverter() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.registerModule(javaTimeModule()); + return new Jackson2JsonMessageConverter(objectMapper); + } + + @Bean + public Module javaTimeModule() { + return new JavaTimeModule() + .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(TIME_FORMAT)) + .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(TIME_FORMAT)); + } +} diff --git a/backend/src/main/java/com/happy/friendogly/config/WebSocketInterceptor.java b/backend/src/main/java/com/happy/friendogly/config/WebSocketInterceptor.java index b38dd5ae2..b781ab729 100644 --- a/backend/src/main/java/com/happy/friendogly/config/WebSocketInterceptor.java +++ b/backend/src/main/java/com/happy/friendogly/config/WebSocketInterceptor.java @@ -19,8 +19,8 @@ @Component public class WebSocketInterceptor implements ChannelInterceptor { - private static final String TOPIC_CHAT_ENDPOINT = "/topic/chat/"; - private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/"; + private static final String TOPIC_CHAT_ENDPOINT = "/exchange/chat.exchange/room."; +// private static final String TOPIC_INVITE_ENDPOINT = "/topic/invite/"; private final ClubRepository clubRepository; private final ChatRoomRepository chatRoomRepository; @@ -50,9 +50,10 @@ public Message preSend(Message message, MessageChannel channel) { String accessToken = accessor.getFirstNativeHeader(AUTHORIZATION); long memberId = validateAndExtractMemberIdFrom(accessToken); - if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) { - validateInviteSubscription(memberId, destination); - } + // TODO: 1대1 채팅방 구현할 때 고려하기 +// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) { +// validateInviteSubscription(memberId, destination); +// } if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) { validateChatSubscription(memberId, destination); @@ -68,9 +69,9 @@ private void validateDestination(String destination) { if (destination.startsWith(TOPIC_CHAT_ENDPOINT)) { return; } - if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) { - return; - } +// if (destination.startsWith(TOPIC_INVITE_ENDPOINT)) { +// return; +// } throw new FriendoglyWebSocketException(String.format("%s는 올바른 sub 경로가 아닙니다.", destination)); } @@ -88,14 +89,14 @@ private long validateAndExtractMemberIdFrom(String accessToken) { } } - private void validateInviteSubscription(long memberId, String destination) { - String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length()); - long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe); - - if (memberId != memberIdToSubscribe) { - throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다."); - } - } +// private void validateInviteSubscription(long memberId, String destination) { +// String rawMemberIdToSubscribe = destination.substring(TOPIC_INVITE_ENDPOINT.length()); +// long memberIdToSubscribe = convertToLong(rawMemberIdToSubscribe); +// +// if (memberId != memberIdToSubscribe) { +// throw new FriendoglyWebSocketException("자신의 초대 endpoint만 구독할 수 있습니다."); +// } +// } private void validateChatSubscription(long memberId, String destination) { String rawChatRoomId = destination.substring(TOPIC_CHAT_ENDPOINT.length()); diff --git a/backend/src/main/java/com/happy/friendogly/config/WebSocketConfig.java b/backend/src/main/java/com/happy/friendogly/config/WebSocketLocalConfig.java similarity index 86% rename from backend/src/main/java/com/happy/friendogly/config/WebSocketConfig.java rename to backend/src/main/java/com/happy/friendogly/config/WebSocketLocalConfig.java index e3b09b25e..f797dc4ee 100644 --- a/backend/src/main/java/com/happy/friendogly/config/WebSocketConfig.java +++ b/backend/src/main/java/com/happy/friendogly/config/WebSocketLocalConfig.java @@ -4,6 +4,7 @@ import com.happy.friendogly.auth.service.jwt.JwtProvider; import java.util.List; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; @@ -11,15 +12,17 @@ import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; +@Profile("local") @Configuration @EnableWebSocketMessageBroker -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { +public class WebSocketLocalConfig implements WebSocketMessageBrokerConfigurer { private final WebSocketInterceptor webSocketInterceptor; private final WebSocketErrorHandler webSocketErrorHandler; private final JwtProvider jwtProvider; - public WebSocketConfig(WebSocketInterceptor webSocketInterceptor, + public WebSocketLocalConfig( + WebSocketInterceptor webSocketInterceptor, WebSocketErrorHandler webSocketErrorHandler, JwtProvider jwtProvider ) { @@ -30,7 +33,7 @@ public WebSocketConfig(WebSocketInterceptor webSocketInterceptor, @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/topic"); + registry.enableSimpleBroker("/exchange"); registry.setApplicationDestinationPrefixes("/publish"); } diff --git a/backend/src/main/java/com/happy/friendogly/config/WebSocketRabbitMqConfig.java b/backend/src/main/java/com/happy/friendogly/config/WebSocketRabbitMqConfig.java new file mode 100644 index 000000000..20c624202 --- /dev/null +++ b/backend/src/main/java/com/happy/friendogly/config/WebSocketRabbitMqConfig.java @@ -0,0 +1,79 @@ +package com.happy.friendogly.config; + +import com.happy.friendogly.auth.WebSocketArgumentResolver; +import com.happy.friendogly.auth.service.jwt.JwtProvider; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Profile("!local") +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketRabbitMqConfig implements WebSocketMessageBrokerConfigurer { + + @Value("${spring.rabbitmq.host}") + private String host; + + @Value("${spring.rabbitmq.username}") + private String username; + + @Value("${spring.rabbitmq.password}") + private String password; + + @Value("${spring.rabbitmq.stomp-port}") + private int port; + + private final WebSocketInterceptor webSocketInterceptor; + private final WebSocketErrorHandler webSocketErrorHandler; + private final JwtProvider jwtProvider; + + public WebSocketRabbitMqConfig(WebSocketInterceptor webSocketInterceptor, + WebSocketErrorHandler webSocketErrorHandler, + JwtProvider jwtProvider + ) { + this.webSocketInterceptor = webSocketInterceptor; + this.webSocketErrorHandler = webSocketErrorHandler; + this.jwtProvider = jwtProvider; + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setPathMatcher(new AntPathMatcher(".")); + + registry.enableStompBrokerRelay("/queue", "/topic", "/exchange", "/amq/queue") + .setClientLogin(username) + .setClientPasscode(password) + .setSystemLogin(username) + .setSystemPasscode(password) + .setRelayHost(host) + .setRelayPort(port) + .setVirtualHost("/"); + + registry.setApplicationDestinationPrefixes("/publish"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/connect") + .setAllowedOrigins("*"); + registry.setErrorHandler(webSocketErrorHandler); + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new WebSocketArgumentResolver(jwtProvider)); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(webSocketInterceptor); + } +} diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 711d0c735..c02450651 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -17,6 +17,12 @@ file: path: firebase-friendogly-private-key.json spring: + rabbitmq: + host: ${RABBITMQ_HOST} + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + port: 5672 + stomp-port: 61613 datasource: driver-class-name: org.h2.Driver url: jdbc:h2:file:/home/ubuntu/h2_test_data;AUTO_SERVER=true diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index 86ef91393..4c280fb8f 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -27,7 +27,10 @@ logging.level.org: SQL: debug orm.jdbc.bind: trace - file: firebase: path: firebase-friendogly-private-key.json +management: + health: + rabbit: + enabled: false diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 1f98d59d6..a85d1aab7 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -32,6 +32,12 @@ file: path: firebase-friendogly-private-key.json spring: + rabbitmq: + host: ${RABBITMQ_PROD_HOST} + username: ${RABBITMQ_PROD_USERNAME} + password: ${RABBITMQ_PROD_PASSWORD} + port: 3100 # initial connection port: use 3100 instead of 5672 + stomp-port: 9100 # STOMP port: use 9100 instead of 61613 sql: init: data-locations: