작성한 코드를 어떤식으로 정리 해야할지 몰라서 나름대로 생각해서 정리해보기로 했습니다.
프로젝트 구조
- main/~/project_name // 소스 코드 디렉토리
- domain // 변수 디렉토리
- search // 외부 api통신의 데이터를 취득 하거나 내부DB에서 검색과 관련 데이터를 이용할때 사용
- SearchResult.java // 내부 DB안에 채널을 검색한 결과를 도출할때 사용
- YoutubeChannelList.java // youtube api( 개인이 구독한 채널에 대한 데이터 )에서 데이터를 입력받을때 사용
- YoutubeIsLive.java // 클라이언트 서버에서 요청할때 채널에 대한 라이브 여부를 송신하기 위해서 사용
- YoutubeSearchResult.java // youtube api( 유저에 대한 데이터 )에서 데이터를 입력받을때 사용
- Follower.java // 구독한 채널에 대한 정보 ( entity )
- LiveConfig.java // 채널에 대한 상세정보 ( entity )
- LoginRequest.java // 클라이언트으로 부터 요청을 받을때 사용하는 폼 ( 로그인 )
- Member.java // 사용자에 대한 정보 ( entity )
- SignupRequest.java // 클라이언트으로 부터 요청을 받을때 사용하는 폼 ( 회원가입 )
- TwitchSearchResult.java // twitch api( 채널검색에 대한 데이터 )에서 데이터를 입력받을때 사용
- mappers // 마이바티스에서 사용할 mapper의 집합 디렉토
- FollowerMapper.java // 구독한 채널에 대한 DB 기능
- LiveConfigMapper.java // 입력된 채널들에 대한 DB
- MemberMapper.java // 유저 정보에 대한 DB 기능
- repository // DB의 데이터들의 이용방법 디렉토리
- FollowerRepository.java // 구독한 채널에 대한 DB 데이터의 기능을 설명하는 추상화
- FollowerRepositoryV1.java // jpa를 이용한 기능 구현
- LiveConfigRepository.java // crudRepository을 활용한 DB liveconfig의 데이터 활용 기능 구현
- MemberRepository.java // 이용자에 대한 DB 데이터의 기능을 설명하는 추상화
- MemberRepositoryV1.java // jpa를 이용한 기능 구현
- service // 데이터와 상호 작용하는 서비스 디렉토리
- CheckYoutubeLiveStreamService.java // 서버내에서 작동하는 채널들의 라이브 판정처리 추상화
- CheckYoutubeLiveStreamServiceV1.java // jpa를 이용한 기능 구현
- CheckYoutubeLiveStreamServiceV2.java // mybatis를 이용한 기능 구현
- MemberService.java // 이용자들이 홈페이지 내에서 이용하는 서비스의 추상화
- MemberServiceV1.java // jpa를 이용한 기능 구현
- MemberServiceV2.java // mybatis를 이용한 기능 구현
- SearchService.java // youtube, twitch api를 이용한 검색에 대한 기능 추상화
- SearchServiceV1.java // HTTP 통신을 통한 API 호출 및 응답 처리
- web // 클라이언트와의 상호작용 처리 디렉토리
- BroadcastSearchController.java // 생방송 관련 클라이언트와 상호작용 처리
- HomeController.java // 이용자의 로그인 및 회원가입과 같은 기능들을 클라이언트와 상호작용 처리
- AsyncConfig.java // 비동기 처리에 대한 상세정보
- MybatisConfig.java // mybatis에 대한 상세정보
- PersonalApplication.java // spring앱 메인
- SchedulingConfig.java // 스케줄링에 대한 상세정보
- ServletInitializer.java // 외부 tomcat 사용을 위한 처리
- SessionManager.java // 세션 처리 관련 기능
- SpringConfig.java // 앱 기능에 대한 상세정보
- resources // 정적 파일 디렉토리
- mappers // mybatis 맵퍼를 저장하는 디렉토리
- FollowerMapper.xml // sql Follower테이블에 접근하는 방법을 정의해놓은 xml
- LiveConfigMapper.xml // sql LiveConfig테이블에 접근하는 방법을 정의해놓은 xml
- MemberMapper.xml // sql follower테이블에 접근하는 방법을 정의해놓은 xml
- application.properties // 하드코딩을 숨기기 위한 properties
- chromedriver.exe // 셀레니움을 활용하기 위한 크롬 드라이버
- mybatis-config.xml // mybatis 상세 설정
의 구조로 백엔드를 구성해 보았다.
V1 하고 V2가 있는데 V1는 일반적인 jpa를 이용한 방법으로 만들어 보았고 V2는 mybatis를 이용한 방법으로 코드를 만들어 보았다.
※ 패키지명은 기입하지 않음
@Setter @Getter
public class SearchResult{
private LiveConfig liveConfig;
private boolean searchResult;
}
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class YoutubeChannelList {
private String nextPageToken;
private List<Items> items;
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Items{
private Snippet snippet;
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Snippet {
private String title;
private String customUrl;
private String description;
private String channelTitle;
private Thumbnails thumbnails;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Thumbnails {
private Image medium;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Image {
private String url;
}
}
}
jackson의 기능
@JsonIgnoreProperties(ignoreUnknown = true) 의 경우는 파싱할때 필요한 데이터만 받기 위해서 입력함
@Data
public class YoutubeIsLive {
private String title;
private String isLive;
}
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class YoutubeSearchResult {
private List<Items> items;
private String nextPageToken;
private PageInfo pageInfo;
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Items{
private Snippet snippet;
private ContentDetails contentDetails;
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Snippet {
private String channelTitle;
private Thumbnails thumbnails;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Thumbnails {
private Image medium;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Image {
private String url;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class ContentDetails {
private Subscription subscription;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class Subscription {
private ResourceId resourceId;
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class ResourceId {
private String channelId;
}
}
@JsonIgnoreProperties(ignoreUnknown = true) @Data
public static class PageInfo {
private int totalResults;
}
}
@Entity
@Data
public class Follower {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "twitch_channel_id")
private String twitch_channel_id;
@ManyToOne
@JoinColumn(name = "member_id", referencedColumnName = "id")
private Member member;
@ManyToOne(cascade = CascadeType.PERSIST)
@JoinColumn(name = "custom_url", referencedColumnName = "custom_url")
private LiveConfig liveConfig;
}
@Entity
@Data
public class LiveConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String name;
private String description;
@Column(name = "custom_url")
private String custom_url;
@Column(name = "thumbnails_url")
private String thumbnails_url;
@Column(name = "is_live")
private String is_live;
@Column(name = "video_id")
private String video_id;
@Column(name = "is_check")
private boolean is_check;
}
@Data
public class LoginRequest {
private String username;
private String password;
}
@Entity
@Data
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", unique = true)
private String username;
@Column(name = "password")
private String password;
@Column(name = "youtube_channel_id")
private String youtubeChannelId;
@OneToMany(mappedBy = "member", cascade = CascadeType.PERSIST)
private List<Follower> followers;
}
아이디(username)가 겹치는건 말이 안되기 때문에 unique 속성을 넣었고 followers를 수정하면서 jpa에서 persist할때 자동으로 같이 입력이 되기를 바라기 때문에 cascade = CascadeType.PERSIST 를 추가하였습니다.
@Data
public class SignupRequest {
private String username;
private String password;
private String youtubeChannelId;
}
회원가입 호출을 받을 시 입력받는 데이터의 종류
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class TwitchSearchResult {
private List<data> data;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class data {
private String broadcaster_login;
private String display_name;
private String is_live;
private String thumbnail_url;
}
}
@Mapper
public interface FollowerMapper {
void save(@Param("memberId") Long memberId, @Param("customUrl") String customUrl);
void delete(Member member, @Param("customUrl") String customUrl);
void updateTwitchChannelId(@Param("twitch_channel_id") String twitch_channel_id, @Param("member_id") Long member_id, @Param("custom_url") String customUrl);
Follower findFollower(Member member, @Param("customUrl") String customUrl);
List<String> findAllFollowerByMemberId(@Param("memberId") Long memberId);
}
@Mapper
public interface LiveConfigMapper {
void save(LiveConfig liveConfig);
void updateLiveConfig(LiveConfig liveConfig);
void setByIsCheckFalseAll();
void delete(LiveConfig liveConfig);
LiveConfig findByCustomUrl(String custom_url);
int existsByCustomUrl(String custom_url);
int countByIsCheckFalse();
List<LiveConfig> findAll();
List<LiveConfig> findByIsCheckFalse();
}
@Mapper
public interface MemberMapper {
void save(Member member);
void delete(Member member);
Member findMember(String username);
}
public interface FollowerRepository {
Follower saveFollower(Follower follower);
Follower deletFollower(Follower follower);
Follower findFollower(Member member, String customUrl);
}
public class FollowerRepositoryV1 implements FollowerRepository{
@PersistenceContext
EntityManager em;
@Override
public Follower saveFollower(Follower follower) {
em.persist(follower);
return follower;
}
@Override
public Follower deletFollower(Follower follower) {
em.remove(follower);
return follower;
}
@Override
public Follower findFollower(Member member, String customUrl) {
Follower follower = em.createQuery("SELECT e FROM Follower e WHERE e.custom_url = :customUrl AND e.member.id = :memberId", Follower.class).
setParameter("customUrl", customUrl).
setParameter("memberId", member.getId()).
getSingleResult();
return follower;
}
}
public interface LiveConfigRepository extends CrudRepository<LiveConfig, Long> {
@Query("SELECT lc FROM LiveConfig lc WHERE lc.custom_url =?1")
LiveConfig findByCustomUrl(String customUrl);
@Query("SELECT COUNT(lc) FROM LiveConfig lc WHERE lc.is_check = false")
Long countByIsCheckFalse();
@Modifying
@Query("UPDATE LiveConfig lc SET lc.is_check = false")
void setByIsCheckFalseAll();
@Query("SELECT lc FROM LiveConfig lc WHERE lc.is_check = false")
List<LiveConfig> findByIsCheckFalse();
}
이렇게 작성은 했는데 코드에 쿼리가 작성되는 것이 맞는건지는 잘 모르겠음
참고로 mybatis의 Mapper의 경우에도 비슷한 방식이 존재함
@Repository
public interface MemberRepository {
Member save(Member member);
Member findMember(String userid);
List<Follower> findAllFollower(Long id);
}
public class MemberRepositoryV1 implements MemberRepository{
@PersistenceContext
EntityManager em;
@Override
public Member save(Member member) {
em.persist(member);
return member;
}
@Override
public Member findMember(String userid) {
try {
Member member = (Member) em.createQuery("SELECT e FROM Member e WHERE e.username = :username",
Member.class).setParameter("username", userid).getSingleResult();
return member;
} catch (Exception e) {
return null;
}
}
@Override
public List<Follower> findAllFollower(Long id) {
Member member = em.find(Member.class, id);
return member.getFollowers();
}
}
public interface CheckYoutubeLiveStreamService {
void checkYoutubeLiveStream() throws InterruptedException;
void checkYoutubeLiveStreaminit() throws InterruptedException;
}
비동기로 하려고 하기 때문에 throws InterruptedException 를 추가함
@Service
public class CheckYoutubeLiveStreamServiceV1 implements CheckYoutubeLiveStreamService {
private LiveConfigRepository liveConfigRepository;
private ChromeOptions chromeOptions;
public CheckYoutubeLiveStreamServiceV1(LiveConfigRepository liveConfigRepository) {
this.liveConfigRepository = liveConfigRepository;
chromeOptions = new ChromeOptions();
chromeOptions.addArguments("--headless");
}
@Override
public void checkYoutubeLiveStream() throws InterruptedException {
List<LiveConfig> liveConfigs = liveConfigRepository.findByIsCheckFalse();
if(liveConfigs.size() > 0){
WebDriver driver = new ChromeDriver(chromeOptions);
for (LiveConfig liveConfig : liveConfigs) {
if(!liveConfig.is_check()){
driver.get("https://www.youtube.com/"+ liveConfig.getCustom_url() + "/streams");
if(isLiveStream(driver)){
setLiveConfigInfo(driver, liveConfig);
}else{
resetLiveConfigInfo(liveConfig);
}
liveConfigRepository.save(liveConfig);
}
}
driver.quit();
}
}
@Override
public void checkYoutubeLiveStreaminit() throws InterruptedException {
Long liveConfigs = liveConfigRepository.countByIsCheckFalse();
Long cnt = liveConfigRepository.count();
if(liveConfigs == 0 && cnt != 0){
liveConfigRepository.setByIsCheckFalseAll();
}
}
private boolean isLiveStream(WebDriver driver) {
return driver.getPageSource().contains("overlay-style=\"LIVE\"") &&
driver.getPageSource().contains("<div class=\"yt-tab-shape-wiz__tab yt-tab-shape-wiz__tab--tab-selected\">라이브</div>");
}
private void setLiveConfigInfo(WebDriver driver, LiveConfig liveConfig) {
liveConfig.setIs_live("Live");
liveConfig.setVideo_id(driver.getPageSource().split("is-live-video")[1].split("content")[0].split("watch?")[1].substring(3, 14));
liveConfig.set_check(true);
}
private void resetLiveConfigInfo(LiveConfig liveConfig) {
liveConfig.setIs_live("");
liveConfig.setVideo_id("");
liveConfig.set_check(true);
}
}
public class CheckYoutubeLiveStreamServiceV2 implements CheckYoutubeLiveStreamService {
private LiveConfigMapper liveConfigMapper;
private ChromeOptions chromeOptions;
public CheckYoutubeLiveStreamServiceV2(LiveConfigMapper liveConfigMapper) {
this.liveConfigMapper = liveConfigMapper;
chromeOptions = new ChromeOptions();
chromeOptions.addArguments("--headless");
}
@Override
public void checkYoutubeLiveStream() throws InterruptedException {
int checkCount = liveConfigMapper.countByIsCheckFalse();
if(checkCount > 0){
List<LiveConfig> liveConfigs = liveConfigMapper.findByIsCheckFalse();
WebDriver driver = new ChromeDriver(chromeOptions);
for (LiveConfig liveConfig : liveConfigs) {
if(!liveConfig.is_check()){
driver.get("https://www.youtube.com/"+ liveConfig.getCustom_url() + "/streams");
if(isLiveStream(driver)){
liveConfig = setLiveConfigInfo(driver, liveConfig);
}else{
liveConfig = resetLiveConfigInfo(liveConfig);
}
liveConfigMapper.updateLiveConfig(liveConfig);
}
}
driver.quit();
}
}
@Override
public void checkYoutubeLiveStreaminit() throws InterruptedException {
int checkCount = liveConfigMapper.countByIsCheckFalse();
if(checkCount == 0){
liveConfigMapper.setByIsCheckFalseAll();
}
}
private boolean isLiveStream(WebDriver driver) {
return driver.getPageSource().contains("overlay-style=\"LIVE\"") &&
driver.getPageSource().contains("<div class=\"yt-tab-shape-wiz__tab yt-tab-shape-wiz__tab--tab-selected\">라이브</div>");
}
private LiveConfig setLiveConfigInfo(WebDriver driver, LiveConfig liveConfig) {
liveConfig.setIs_live("Live");
liveConfig.setVideo_id(driver.getPageSource().split("is-live-video")[1].split("content")[0].split("watch?")[1].substring(3, 14));
liveConfig.set_check(true);
return liveConfig;
}
private LiveConfig resetLiveConfigInfo(LiveConfig liveConfig) {
liveConfig.setIs_live("");
liveConfig.setVideo_id("");
liveConfig.set_check(true);
return liveConfig;
}
}
동작은 위와 동일
public interface MemberService {
void join(Member member);
boolean checkFollow(Member member, String customUrl);
void follow(Member member, Follower follower);
void setTwitchChannelId(Follower follower, String channelId);
void unFollow(Member member, String follower);
Member findUser(String username);
Follower findFollower(Member member, String youtubeChannelId);
LiveConfig searchChannel(String customUrl);
}
@Service
@Transactional
public class MemberServiceV1 implements MemberService{
private MemberRepository memberRepository;
private FollowerRepository followerRepository;
private LiveConfigRepository liveConfigRepository;
private SearchService searchService;
public MemberServiceV1(MemberRepository memberRepository, FollowerRepository followerRepository, LiveConfigRepository liveConfigRepository, SearchService searchService) {
this.memberRepository = memberRepository;
this.followerRepository = followerRepository;
this.liveConfigRepository = liveConfigRepository;
this.searchService = searchService;
}
/**
* @param member
*/
@Override
public void join(Member member){
YoutubeSearchResult youtubeSearchResult;
List<Follower> followerList = new ArrayList<>();
String searchURLs = "";
String nextPage = "";
do{
youtubeSearchResult = searchService.getYoutubeSubscribeList(member.getYoutubeChannelId(), "50", nextPage);
searchURLs = "";
for (Items result : youtubeSearchResult.getItems()) {
if(result.getContentDetails().getSubscription() != null)
searchURLs += "&id=" + result.getContentDetails().getSubscription().getResourceId().getChannelId();
}
if(!searchURLs.equals("")){
YoutubeChannelList youtubeChannelList = searchService.getChannelUrlList(searchURLs);
for (YoutubeChannelList.Items result : youtubeChannelList.getItems()) {
Follower follower = new Follower();
LiveConfig liveConfig = Optional.ofNullable(liveConfigRepository.findByCustomUrl(result.getSnippet().getCustomUrl())).orElse(null);
if(liveConfig == null){
liveConfig = new LiveConfig();
liveConfig.setName(result.getSnippet().getTitle());
String description = result.getSnippet().getDescription();
if (description != null && description.length() > 100) {
liveConfig.setDescription(description.substring(0, 100));
} else {
liveConfig.setDescription(description);
}
liveConfig.setCustom_url(result.getSnippet().getCustomUrl());
liveConfig.setThumbnails_url(result.getSnippet().getThumbnails().getMedium().getUrl());
}
follower.setLiveConfig(liveConfig);
follower.setMember(member);
follower.setTwitch_channel_id("");
followerList.add(follower);
}
nextPage = Optional.ofNullable(youtubeSearchResult.getNextPageToken()).orElse("");
}
}while(!nextPage.equals(""));
member.setFollowers(followerList);
memberRepository.save(member);
}
/**
* @param member
* @param follower
* @return boolean
*/
@Override
public boolean checkFollow(Member member, String customUrl){
for (Follower follower : member.getFollowers()) {
if (follower.getLiveConfig().getCustom_url().equals(customUrl)) {
return false;
}
}
return true;
}
/**
* @param member
* @param follower
*/
@Override
public void follow(Member member, Follower follower){
member.getFollowers().add(follower);
memberRepository.save(member);
}
/**
* @param member
* @param follower
*/
@Override
public void setTwitchChannelId(Follower follower, String twitchChannelId) {
follower.setTwitch_channel_id(twitchChannelId);
followerRepository.saveFollower(follower);
}
/**
* @param member
* @param follower
*/
@Override
public void unFollow(Member member, String follower){
followerRepository.deletFollower(followerRepository.findFollower(member, follower));
}
/**
* @param id
* @return Member
*/
@Override
public Member findUser(String username){
Member member = memberRepository.findMember(username);
return member;
}
/**
* @param member
* @param youtubeChannelId
* @return Follower
*/
@Override
public Follower findFollower(Member member, String youtubeChannelId){
return followerRepository.findFollower(member, youtubeChannelId);
}
/**
* @param customUrl
* @return LiveConfig
*/
@Override
public LiveConfig searchChannel(String customUrl){
return Optional.ofNullable(liveConfigRepository.findByCustomUrl(customUrl)).orElse(null);
}
}
public class MemberServiceV2 implements MemberService{
private final FollowerMapper followerMapper;
private final LiveConfigMapper liveConfigMapper;
private final MemberMapper memberMapper;
private final SearchService searchService;
public MemberServiceV2(FollowerMapper followerMapper, LiveConfigMapper liveConfigMapper, MemberMapper memberMapper, SearchService searchService) {
this.followerMapper = followerMapper;
this.liveConfigMapper = liveConfigMapper;
this.memberMapper = memberMapper;
this.searchService = searchService;
}
@Override
public void join(Member member) {
memberMapper.save(member);
YoutubeSearchResult youtubeSearchResult;
Long memberId = memberMapper.findMember(member.getUsername()).getId();
String searchURLs = "";
String nextPage = "";
do{
youtubeSearchResult = searchService.getYoutubeSubscribeList(member.getYoutubeChannelId(), "50", nextPage);
searchURLs = "";
for (Items result : youtubeSearchResult.getItems()) {
if(result.getContentDetails().getSubscription() != null)
searchURLs += "&id=" + result.getContentDetails().getSubscription().getResourceId().getChannelId();
}
if(!searchURLs.equals("")){
YoutubeChannelList youtubeChannelList = searchService.getChannelUrlList(searchURLs);
for (YoutubeChannelList.Items result : youtubeChannelList.getItems()) {
LiveConfig liveConfig = Optional.ofNullable(liveConfigMapper.findByCustomUrl(result.getSnippet().getCustomUrl())).orElse(null);
if(liveConfig == null){
liveConfig = new LiveConfig();
liveConfig.setName(result.getSnippet().getTitle());
String description = result.getSnippet().getDescription();
if (description != null && description.length() > 100) {
liveConfig.setDescription(description.substring(0, 100));
} else {
liveConfig.setDescription(description);
}
liveConfig.setCustom_url(result.getSnippet().getCustomUrl());
liveConfig.setThumbnails_url(result.getSnippet().getThumbnails().getMedium().getUrl());
liveConfig.setVideo_id("");
liveConfig.setIs_live("");
liveConfigMapper.save(liveConfig);
}
followerMapper.save(memberId, liveConfig.getCustom_url());
}
nextPage = Optional.ofNullable(youtubeSearchResult.getNextPageToken()).orElse("");
}
}while(!nextPage.equals(""));
}
@Override
public boolean checkFollow(Member member, String customUrl) {
for (Follower follower : member.getFollowers()) {
if (follower.getLiveConfig().getCustom_url().equals(customUrl)) {
return false;
}
}
return true;
}
@Override
public void follow(Member member, Follower follower) {
followerMapper.save(member.getId(), follower.getLiveConfig().getCustom_url());
}
@Override
public void setTwitchChannelId(Follower follower, String twitchChannelId) {
followerMapper.updateTwitchChannelId(twitchChannelId, follower.getId(), follower.getLiveConfig().getCustom_url());
}
@Override
public void unFollow(Member member, String follower) {
followerMapper.delete(member, follower);
}
@Override
public Member findUser(String username) {
Member member = Optional.ofNullable(memberMapper.findMember(username)).orElse(null);
if(member == null){
return null;
}
List<String> urlList = followerMapper.findAllFollowerByMemberId(member.getId());
List<Follower> followers = setFollowers(member, urlList);
member.setFollowers(followers);
return member;
}
@Override
public Follower findFollower(Member member, String youtubeChannelId) {
return followerMapper.findFollower(member, youtubeChannelId);
}
@Override
public LiveConfig searchChannel(String customUrl) {
return liveConfigMapper.findByCustomUrl(customUrl);
}
public List<Follower> setFollowers(Member member, List<String> urlList) {
List<Follower> followers = new ArrayList<Follower>();
for(String url : urlList) {
Follower follower = new Follower();
follower.setId(member.getId());
follower.setLiveConfig(liveConfigMapper.findByCustomUrl(url));
followers.add(follower);
}
return followers;
}
}
public interface SearchService {
YoutubeSearchResult youtubeSearchChannel(String query);
YoutubeSearchResult getYoutubeSubscribeList(String youtubeChannelId, String maxResults, String pageToken);
YoutubeChannelList getChannelUrlList(String youtubeChannelIds);
TwitchSearchResult twitchSearchChannel(String query);
}
public class SearchServiceV1 implements SearchService {
@Value("${youtube.api.key}")
private String youtubeApiKey;
@Value("${twitch.api.client.id}")
private String twitchClientId;
@Value("${twitch.api.client.acces_token}")
private String twitchStringToken;
@Override
public YoutubeSearchResult youtubeSearchChannel(String query) {
ObjectMapper objectMapper = new ObjectMapper();
try {
String encodedQuery = URLEncoder.encode(query, "UTF-8");
String url = "https://youtube.googleapis.com/youtube/v3/search?part=snippet&channelType=any&maxResults=25&q=" + encodedQuery + "&type=channel&key=" + youtubeApiKey; // JSON 데이터를 가져올 URL을 지정합니다.
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
String jsonResponse = response.toString();
YoutubeSearchResult searchResult = objectMapper.readValue(jsonResponse, YoutubeSearchResult.class);
return searchResult;
} else {
System.out.println("HTTP 요청 실패: " + responseCode);
}
} catch (IOException e) {
e.printStackTrace();
}
return new YoutubeSearchResult();
}
@Override
public YoutubeSearchResult getYoutubeSubscribeList(String youtubeChannelId, String maxResults, String pageToken) {
ObjectMapper objectMapper = new ObjectMapper();
try {
String url = "https://www.googleapis.com/youtube/v3/activities?part=snippet%2CcontentDetails"
+"&channelId=" + youtubeChannelId
+"&maxResults="+ maxResults
+"&pageToken="+ pageToken
+ "&key=" + youtubeApiKey;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
String jsonResponse = response.toString();
YoutubeSearchResult searchResult = objectMapper.readValue(jsonResponse, YoutubeSearchResult.class);
return searchResult;
} else {
System.out.println("HTTP 요청 실패: " + responseCode);
}
} catch (IOException e) {
e.printStackTrace();
}
return new YoutubeSearchResult();
}
@Override
public YoutubeChannelList getChannelUrlList(String youtubeChannelIds) {
ObjectMapper objectMapper = new ObjectMapper();
try {
String url = "https://www.googleapis.com/youtube/v3/channels?part=snippet&maxResults=50"
+ youtubeChannelIds + "&key=" + youtubeApiKey;;
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
String jsonResponse = response.toString();
YoutubeChannelList searchResult = objectMapper.readValue(jsonResponse, YoutubeChannelList.class);
return searchResult;
} else {
System.out.println("HTTP 요청 실패: " + responseCode);
}
} catch (IOException e) {
e.printStackTrace();
}
return new YoutubeChannelList();
}
@Override
public TwitchSearchResult twitchSearchChannel(String query) {
ObjectMapper objectMapper = new ObjectMapper();
try {
String encodedQuery = URLEncoder.encode(query, "UTF-8");
String url = "https://api.twitch.tv/helix/search/channels?query=" + encodedQuery; // JSON 데이터를 가져올 URL을 지정합니다.
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Authorization", "Bearer " + twitchStringToken);
connection.setRequestProperty("Client-Id", twitchClientId);
int responseCode = connection.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_OK) {
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
String inputLine;
StringBuffer response = new StringBuffer();
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
in.close();
String jsonResponse = response.toString();
TwitchSearchResult TwitchSearchResult = objectMapper.readValue(jsonResponse, TwitchSearchResult.class);
return TwitchSearchResult;
} else {
System.out.println("HTTP 요청 실패: " + responseCode);
}
} catch (IOException e) {
e.printStackTrace();
}
return new TwitchSearchResult();
}
}
@Controller
@CrossOrigin(origins = "${cors.origins}", allowCredentials = "true")
public class BroadcastSearchController {
public final MemberService memberService;
public final SearchService searchService;
public final SessionManager sessionManager;
public BroadcastSearchController(MemberService memberService, SessionManager sessionManager, SearchService searchService) {
this.memberService = memberService;
this.sessionManager = sessionManager;
this.searchService = searchService;
}
@GetMapping("/follow")
@ResponseBody
public void follow(Model model,
@RequestParam("customUrl") String customUrl,
HttpServletRequest request
){
Member member = (Member)sessionManager.getSession(request);
LiveConfig liveConfig = memberService.searchChannel(customUrl);
// liveConfig 가 존재하지 않은 경우에는 동작하지 않는다.
// 해당의 경우에는 에러를 보내거나 하는것이 옳을것으로 보인다.
// 따로 안내를 해야할 것 같다. => Refresh 함수 같은것이 필요로 할것으로 보인다.
if(member != null && liveConfig!= null){
member = memberService.findUser(member.getUsername());
if(memberService.checkFollow(member, customUrl)){
Follower follower = new Follower();
follower.setLiveConfig(liveConfig);
follower.setMember(member);
memberService.follow(member, follower);
}else{
// 중복이 있는경우 이므로 조치를 해야한다.
}
}
}
@GetMapping("/unFollow")
@ResponseBody
public void unFollow(Model model,
@RequestParam("userId") String userId,
@RequestParam("youtubeChannelId") String youtubeChannelId,
HttpServletRequest request
){
if(sessionManager.getSession(request) != null){
Member member = memberService.findUser(userId);
memberService.unFollow(member, youtubeChannelId);
}
}
@GetMapping("/searchChannel")
@ResponseBody
public SearchResult searchChannel(Model model,
@RequestParam("search") String customUrl,
HttpServletRequest request
){
SearchResult searchResult = new SearchResult();
if(sessionManager.getSession(request) != null){
LiveConfig liveConfig = memberService.searchChannel(customUrl);
searchResult.setLiveConfig(liveConfig);
if(liveConfig != null) searchResult.setSearchResult(true);
else searchResult.setSearchResult(false);
return searchResult;
}
searchResult.setSearchResult(false);
return searchResult;
}
@GetMapping("/getFollowChannels")
@ResponseBody
public List<LiveConfig> getFollowChannels(Model model,
HttpServletRequest request
){
Member member = (Member)sessionManager.getSession(request);
if(member != null){
member = memberService.findUser(member.getUsername());
List<Follower> followers = member.getFollowers();
List<LiveConfig> configs = new ArrayList<>();
for(Follower follower : followers){
configs.add(follower.getLiveConfig());
}
return configs;
}
return new ArrayList<LiveConfig>();
}
@GetMapping("/getLiveStreamTwitchChannelId")
@ResponseBody
public String getLiveStreamTwitchChannelId(Model model,
@RequestParam("custumUrl") String customUrl,
HttpServletRequest request
){
Member member = (Member)sessionManager.getSession(request);
if(member != null){
member = memberService.findUser(member.getUsername());
LiveConfig liveConfig = memberService.searchChannel(customUrl);
List<Follower> followers = member.getFollowers();
for(Follower follower : followers){
if(follower.getLiveConfig().equals(liveConfig)){
return follower.getTwitch_channel_id();
}
}
}
return "";
}
@GetMapping("/setLiveStreamTwitchChannelId")
@ResponseBody
public boolean setLiveStreamTwitchChannelId(Model model,
@RequestParam("custumUrl") String custumUrl,
@RequestParam("twitchChannelId") String twitchChannelId,
HttpServletRequest request
){
Member member = (Member)sessionManager.getSession(request);
if(member != null){
member = memberService.findUser(member.getUsername());
LiveConfig liveConfig = memberService.searchChannel(custumUrl);
List<Follower> followers = member.getFollowers();
for(Follower follower : followers){
if(follower.getLiveConfig().equals(liveConfig)){
follower.setTwitch_channel_id(twitchChannelId);
memberService.setTwitchChannelId(follower, twitchChannelId);
return true;
}
}
}
return false;
}
@GetMapping("/twitchSearchChannel")
@ResponseBody
public TwitchSearchResult twitchSearchChannel(Model model, @RequestParam("search") String query,
HttpServletRequest request) {
if(sessionManager.getSession(request)!= null){
TwitchSearchResult twitchSearchResult = searchService.twitchSearchChannel(query);
return twitchSearchResult;
}
return new TwitchSearchResult();
}
}
@Controller
@CrossOrigin(origins = "${cors.origins}", allowCredentials = "true")
public class HomeController {
public final MemberService memberService;
public final SessionManager sessionManager;
public HomeController(MemberService memberService , SessionManager sessionManager){
this.memberService = memberService;
this.sessionManager = sessionManager;
}
@PostMapping("/signup")
@ResponseBody
public String signUp(@RequestBody SignupRequest request){
Member member = new Member();
member.setUsername(request.getUsername());
member.setPassword(request.getPassword());
member.setYoutubeChannelId(request.getYoutubeChannelId());
// 중복 체크
if(memberService.findUser(member.getUsername()) != null){
return "false";
}
// youtube channel id check 가 필요하다.
memberService.join(member);
return "true";
}
@PostMapping("/login")
@ResponseBody
public String login(@RequestBody LoginRequest request, HttpServletResponse response){
LoginRequest loginRequest = new LoginRequest();
loginRequest.setUsername(request.getUsername()); //username
loginRequest.setPassword(request.getPassword()); //password
Member member;
// send session cookie
member = memberService.findUser(loginRequest.getUsername());
if(member!= null && member.getPassword().equals(loginRequest.getPassword())){
sessionManager.createSession(member, response);
return "true";
}
return "false";
}
@GetMapping("/logout")
@ResponseBody
public boolean logout(HttpServletRequest request){
sessionManager.expire(request);
return true;
}
@GetMapping("/checkLogin")
@ResponseBody
public boolean checkCookies(Model model, HttpServletRequest request){
if(sessionManager.getSession(request) != null){
return true;
}
return false;
}
}
@Configuration
@EnableAsync
@EnableScheduling
public class AsyncConfig {
@Bean(name = "checkYoutubeLiveStream")
public TaskExecutor checkYoutubeLiveStreamConfig() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 기본 스레드 수
executor.setMaxPoolSize(8); // 최대 스레드 수
executor.setQueueCapacity(10000); // 큐 크기
executor.setThreadNamePrefix("checkYoutubeLiveStream");
executor.initialize();
return executor;
}
@Bean(name = "checkYoutubeLiveStreamInit")
public TaskExecutor checkYoutubeLiveStreamInitConfig() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1); // 기본 스레드 수
executor.setMaxPoolSize(1); // 최대 스레드 수
executor.setQueueCapacity(10000); // 큐 크기
executor.setThreadNamePrefix("checkYoutubeLiveStreamInit");
executor.initialize();
return executor;
}
}
@Configuration
@MapperScan("space.personal.mappers")
public class MybatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactoryBean = new SqlSessionFactoryBean();
sessionFactoryBean.setDataSource(dataSource);
sessionFactoryBean.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:mybatis-config.xml"));
return sessionFactoryBean.getObject();
}
@Bean
public DataSourceTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@SpringBootApplication
public class PersonalApplication {
public static void main(String[] args) {
SpringApplication.run(PersonalApplication.class, args);
}
@PostConstruct
public void init() {
// Chrome 웹 드라이버 경로를 설정
System.setProperty("webdriver.chrome.driver", "src/main/resources/chromedriver.exe");
}
}
셀레니움을 사용하기 때문에 드라이브를 서버 실행후 세팅되게 설정
@Configuration
public class SchedulingConfig {
private final CheckYoutubeLiveStreamService checkYoutubeLiveStreamService;
public SchedulingConfig(CheckYoutubeLiveStreamService checkYoutubeLiveStreamService){
this.checkYoutubeLiveStreamService = checkYoutubeLiveStreamService;
}
@Async("checkYoutubeLiveStream")
@Scheduled(fixedRate = 5000)
public void checkYoutubeLiveStream() throws InterruptedException{
checkYoutubeLiveStreamService.checkYoutubeLiveStream();
}
@Async("checkYoutubeLiveStreamInit")
@Scheduled(fixedRate = 60000)
public void checkYoutubeLiveStreaminit() throws InterruptedException{
checkYoutubeLiveStreamService.checkYoutubeLiveStreaminit();
}
}
youtube라이브 체크는 5초마다 실행하도록 설정했고
데이터베이스에 존재하는 체크포인트를 검사하는건 1분으로 설정해 두었습니다.
이렇게 하면 전체적으로 전부 체크를 했는지 검사하는건 1분마다 검사하고 라이브 체크하는건 5초간격으로 검사하기 때문에 검사 속도가 빨라졌습니다.
public class ServletInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(PersonalApplication.class);
}
}
외부 tomcat으로 배포를 해보고 싶어서 SpringBootServletInitializer를 사용해 보았다.
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "PIViewerSessionCookie";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
public void createSession(Object value, HttpServletResponse response) {
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessionCookie);
}
public Object getSession(HttpServletRequest request) {
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
public void expire(HttpServletRequest request){
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if (sessionCookie == null) {
return;
}
sessionStore.remove(sessionCookie.getValue());
}
public Cookie findCookie(HttpServletRequest request, String cookieName) {
if(request.getCookies() == null){
return null;
}
return Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny()
.orElse(null);
}
}
다중 서버를 운영할 목적은 아니기 때문에 서버내의 세션을 구성해 보았습니다.
@Configuration
public class SpringConfig {
private final FollowerMapper followerMapper;
private final LiveConfigMapper liveConfigMapper;
private final MemberMapper memberMapper;
public SpringConfig(FollowerMapper followerMapper, LiveConfigMapper liveConfigMapper, MemberMapper memberMapper){
this.followerMapper = followerMapper;
this.liveConfigMapper = liveConfigMapper;
this.memberMapper = memberMapper;
}
@Bean
public MemberRepository memberRepository(){
return new MemberRepositoryV1();
}
@Bean
public FollowerRepository followerRepository(){
return new FollowerRepositoryV1();
}
@Bean
public MemberService memberService(){
return new MemberServiceV2(
followerMapper,
liveConfigMapper,
memberMapper,
searchService());
}
@Bean
public CheckYoutubeLiveStreamService checkYoutubeLiveStreamService(){
return new CheckYoutubeLiveStreamServiceV2(liveConfigMapper);
}
@Bean
public SearchService searchService(){
return new SearchServiceV1();
}
}
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="space.personal.mappers.FollowerMapper">
<insert id="save" parameterType="map">
INSERT INTO follower (member_id, custom_url) VALUES (#{memberId}, #{customUrl})
</insert>
<delete id="delete" parameterType="map">
DELETE FROM follower WHERE member_id = #{member.id} AND custom_url = #{customUrl}
</delete>
<update id="updateTwitchChannelId" parameterType="map">
UPDATE follower
SET twitch_channel_id = #{twitch_channel_id}
WHERE follower.member_id = #{member_id} AND
custom_url = #{custom_url}
</update>
<select id="findFollower" parameterType="map" resultType="Follower">
SELECT * FROM follower WHERE member_id = #{member.id} AND custom_url = #{customUrl}
</select>
<select id="findAllFollowerByMemberId" parameterType="Long" resultType="String">
SELECT custom_url FROM follower WHERE member_id = #{memberId}
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="space.personal.mappers.LiveConfigMapper">
<insert id="save" parameterType="LiveConfig">
INSERT INTO live_config (name, custom_url, description, thumbnails_url, is_check, is_live, video_id)
VALUES (#{name}, #{custom_url}, #{description}, #{thumbnails_url}, 0, #{is_live}, #{video_id})
</insert>
<update id="updateLiveConfig" parameterType="LiveConfig">
UPDATE live_config
SET is_live = #{is_live},
video_id = #{video_id},
is_check = #{is_check}
WHERE id = #{id}
</update>
<update id="setByIsCheckFalseAll">
UPDATE live_config SET is_check = FALSE
</update>
<delete id="delete" parameterType="LiveConfig">
DELETE FROM live_config WHERE name = #{name}
</delete>
<select id="findByCustomUrl" parameterType="String" resultType="LiveConfig">
SELECT * FROM live_config WHERE custom_url = #{custom_url}
</select>
<select id="existsByCustomUrl" resultType="boolean" parameterType="String">
SELECT COUNT(*)
FROM follower
WHERE custom_url = #{custom_url}
</select>
<select id="countByIsCheckFalse" resultType="int">
SELECT COUNT(*) FROM live_config WHERE is_check = FALSE
</select>
<select id="findAll" resultType="LiveConfig">
SELECT * FROM live_config
</select>
<select id="findByIsCheckFalse" resultType="LiveConfig">
SELECT * FROM live_config WHERE is_check = FALSE
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="space.personal.mappers.MemberMapper">
<insert id="save" parameterType="Member">
INSERT INTO member (username, password, youtube_channel_id) VALUES (#{username}, #{password}, #{youtubeChannelId})
</insert>
<delete id="delete" parameterType="Member">
DELETE FROM member WHERE username = #{username}
</delete>
<select id="findMember" resultType="Member">
SELECT * FROM member WHERE username = #{username}
</select>
</mapper>
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<properties resource="application.properties" />
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
</settings>
<typeAliases>
<package name="space.personal.domain"/>
</typeAliases>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="UNPOOLED">
<property name="driver" value="${spring.datasource.driver-class-name}"/>
<property name="url" value="${spring.datasource.url}"/>
<property name="username" value="${spring.datasource.username}"/>
<property name="password" value=""/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mappers/FollowerMapper.xml"/>
<mapper resource="mappers/MemberMapper.xml"/>
<mapper resource="mappers/LiveConfigMapper.xml"/>
</mappers>
</configuration>
이상으로 프로젝트에서 구성한 백엔드의 소스 코드였습니다.
개발하면서 생긴 문제점들이 상당히 많지만 특별히 기억나는 문제점은
V1을 개발할때 member를 비롯한 entity를 구성할때 습관적으로 카멜케이스로 변수명을 작성했었는데 spring에서 자동으로 생성할때 스네이크 표기법으로 입력이 되는 것 때문에 mybatis를 추가하는 과정에서 entity를 구성하는 모든곳을 스네이크 표기법으로 바꿔야 했습니다.
최대한 추상화라던지 하드코딩을 하지 않기 위해서 노력했습니다.
보안 요소 같은 요소가 추가되지 않고
회원가입, 로그인, 방송 시청( 채팅 검색 및 활성화 ) 와 같이 기본적인 기능들을 구현하였습니다.
properties의 대한 정보는 생략했습니다.
'토이프로젝트' 카테고리의 다른 글
토이프로젝트 - PIViewer(6) 배포하기 (0) | 2023.12.08 |
---|---|
토이프로젝트 - PIViewer(5) frontend (0) | 2023.11.30 |
토이프로젝트 - PIViewer(3) (0) | 2023.11.12 |
토이프로젝트 - PIViewer(2) (0) | 2023.11.10 |
토이프로젝트 - PIViewer(1) (0) | 2023.11.10 |