Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: 사용자는 도메인 검색 자동 완성을 사용할 수 있다. #57

Merged
merged 5 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,15 @@ operation::hub-controller-test/find-hub[snippets='http-request,path-parameters']
==== response

operation::hub-controller-test/find-hub[snippets='http-response']

== 도메인

=== 루트 도메인 목록 조회

==== request

operation::domain-controller-test/find-root-domains[snippets='http-request,query-parameters']

==== response

operation::domain-controller-test/find-root-domains[snippets='http-response,response-fields']
93 changes: 93 additions & 0 deletions src/main/java/com/seong/shoutlink/domain/common/Trie.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.seong.shoutlink.domain.common;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Getter;

@Getter
public class Trie {

@Getter
static class Node {

private final char c;
private final Map<Character, Node> children = new HashMap<>();
private boolean isWord;

public Node(char c) {
this.c = c;
}

public void setWord(boolean word) {
isWord = word;
}

public boolean hasChildren(char c) {
return children.containsKey(c);
}

public boolean isWord() {
return isWord;
}

public void addSuggestions(String word, List<String> suggestions, int count) {
children.forEach((character, childNode) -> {
String suggestionsWord = word + character;
if (childNode.isWord()) {
suggestions.add(suggestionsWord);
}
if (suggestions.size() >= count) {
return;
}
childNode.addSuggestions(suggestionsWord, suggestions, count);
});
}
}

private static final int MAX_SUGGESTION = 100;
private static final int MAX_WORD_LENGTH = 35;
private static final int MAX_PREFIX_LENGTH = 30;
public static final int ZERO = 0;

private final Node root = new Node(' ');

public synchronized void insert(String word) {
if(word.length() > MAX_WORD_LENGTH) {
return;
}

Node currentNode = root;
for (char c : word.toCharArray()) {
Map<Character, Node> children = currentNode.getChildren();
currentNode = children.getOrDefault(c, new Node(c));
children.put(c, currentNode);
}
currentNode.setWord(true);
}

public List<String> search(String prefix, int count) {
if(prefix.length() > MAX_PREFIX_LENGTH) {
prefix = prefix.substring(ZERO, MAX_PREFIX_LENGTH);
}
Node currentNode = root;
for (char c : prefix.toCharArray()) {
if (!currentNode.hasChildren(c)) {
return List.of();
}
Map<Character, Node> children = currentNode.getChildren();
currentNode = children.get(c);
}
return findWords(prefix, currentNode, Math.min(MAX_SUGGESTION, count));
}

private List<String> findWords(String word, Node currentNode, int count) {
List<String> suggestions = new ArrayList<>();
if (currentNode.isWord()) {
suggestions.add(word);
}
currentNode.addSuggestions(word, suggestions, count);
return suggestions;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.seong.shoutlink.domain.domain.controller;

import com.seong.shoutlink.domain.domain.controller.request.FindRootDomainsRequest;
import com.seong.shoutlink.domain.domain.service.DomainService;
import com.seong.shoutlink.domain.domain.service.request.FindRootDomainsCommand;
import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/domains")
public class DomainController {

private final DomainService domainService;

@GetMapping("/search")
public ResponseEntity<FindRootDomainsResponse> findRootDomains(
@ModelAttribute @Valid FindRootDomainsRequest request) {
FindRootDomainsResponse response = domainService.findRootDomains(
new FindRootDomainsCommand(request.keyword(), request.size()));
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.seong.shoutlink.domain.domain.controller.request;

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.Objects;

public record FindRootDomainsRequest(
@NotNull(message = "검색어는 필수입니다.")
@Size(min = 1, message = "검색어는 1자 이상이어야 합니다.")
String keyword,
Integer size) {

public FindRootDomainsRequest(String keyword, Integer size) {
this.keyword = keyword;
this.size = Objects.isNull(size) ? 10 : size;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.seong.shoutlink.domain.domain.repository;

import java.util.List;

public interface DomainCacheRepository {

List<String> findRootDomains(String prefix, int count);

void insert(String rootDomain);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.seong.shoutlink.domain.domain.repository;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface DomainJpaRepository extends JpaRepository<DomainEntity, Long> {

Optional<DomainEntity> findByRootDomain(String rootDomain);

@Query("select d.rootDomain from DomainEntity d")
List<String> findRootDomains();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.seong.shoutlink.domain.domain.repository;

import com.seong.shoutlink.domain.common.Trie;
import java.util.List;
import org.springframework.stereotype.Repository;

@Repository
public class DomainMemoryRepository implements DomainCacheRepository {

private final Trie trie = new Trie();

@Override
public List<String> findRootDomains(String prefix, int count) {
return trie.search(prefix, count);
}

@Override
public void insert(String rootDomain) {
trie.insert(rootDomain);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.seong.shoutlink.domain.domain.Domain;
import com.seong.shoutlink.domain.domain.service.DomainRepository;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
Expand All @@ -11,6 +12,7 @@
public class DomainRepositoryImpl implements DomainRepository {

private final DomainJpaRepository domainJpaRepository;
private final DomainCacheRepository domainCacheRepository;

@Override
public Optional<Domain> findByRootDomain(String rootDomain) {
Expand All @@ -23,4 +25,15 @@ public Domain save(Domain domain) {
return domainJpaRepository.save(DomainEntity.create(domain))
.toDomain();
}

@Override
public List<String> findRootDomains(String keyword, int size) {
return domainCacheRepository.findRootDomains(keyword, size);
}

@Override
public void synchronizeRootDomains() {
domainJpaRepository.findRootDomains().forEach(domainCacheRepository::insert);

}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.seong.shoutlink.domain.domain.service;

import com.seong.shoutlink.domain.domain.Domain;
import java.util.List;
import java.util.Optional;

public interface DomainRepository {

Optional<Domain> findByRootDomain(String rootDomain);

Domain save(Domain domain);

List<String> findRootDomains(String keyword, int size);

void synchronizeRootDomains();
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.seong.shoutlink.domain.domain.service;

import com.seong.shoutlink.domain.domain.Domain;
import com.seong.shoutlink.domain.domain.service.request.FindRootDomainsCommand;
import com.seong.shoutlink.domain.domain.service.request.UpdateDomainCommand;
import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse;
import com.seong.shoutlink.domain.domain.service.response.UpdateDomainResponse;
import com.seong.shoutlink.domain.domain.util.DomainExtractor;
import com.seong.shoutlink.domain.exception.ErrorCode;
import com.seong.shoutlink.domain.exception.ShoutLinkException;
import com.seong.shoutlink.domain.link.Link;
import com.seong.shoutlink.domain.link.service.LinkRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -34,4 +37,10 @@ public UpdateDomainResponse updateDomain(UpdateDomainCommand command) {
linkRepository.updateLinkDomain(link, domain);
return new UpdateDomainResponse(domain.getDomainId());
}

public FindRootDomainsResponse findRootDomains(FindRootDomainsCommand command) {
List<String> rootDomains = domainRepository.findRootDomains(command.keyword(),
command.size());
return FindRootDomainsResponse.from(rootDomains);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.seong.shoutlink.domain.domain.service.request;

public record FindRootDomainsCommand(String keyword, int size) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.seong.shoutlink.domain.domain.service.response;

import java.util.List;

public record FindRootDomainsResponse(List<String> rootDomains) {

public static FindRootDomainsResponse from(List<String> rootDomains) {
return new FindRootDomainsResponse(rootDomains);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.seong.shoutlink.global.config;

import com.seong.shoutlink.domain.domain.service.DomainRepository;
import com.seong.shoutlink.global.scheduler.DomainScheduler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulerConfig {

@Bean
public DomainScheduler domainScheduler(DomainRepository domainRepository) {
return new DomainScheduler(domainRepository);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.seong.shoutlink.global.scheduler;

import com.seong.shoutlink.domain.domain.service.DomainRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.annotation.Scheduled;

@RequiredArgsConstructor
public class DomainScheduler {

private final DomainRepository domainRepository;

@Scheduled(cron = "0 0 * * * *")
public void synchronizeRootDomains() {
domainRepository.synchronizeRootDomains();
}
}
Loading
Loading