지난 게시글에서 만든 회원 서비스에 CAD 파일 관리 기능을 추가한 것이다. 그렇다고 지난 내용이 이어지지 않으니 본 게시물만 읽어도 충분하다.
코드를 보기 전에 본 웹 서비스에 대해 짧게 알아보자. 사용자는 웹 인터페이스를 통해 서버와 통신한다. 본 서비스는 2가지 기능을 제공한다.
전체 과정은 클라이언트가 도면 설계 폴더를(CAD 파일 포함) S3에 업로드하면 폴더 이름과 업로드 유저 이름을 서버로 보낸다. 그래서 서버가 동작하기에 앞서 CAD파일은 S3 버킷에 프로젝트 별로 저장되어 있다.
결국 중요한건 POST 요청이 들어오는 시점에 S3 버킷 내에 CAD파일이 존재한다는 것이고, POST 요청에는 CAD 파일이 저장된 폴더 이름(ex: ~스마트도시 정보통신 설계용역/)과 업로드 직원 이름(ex: gori)가 포함된다는 것이다.
서버는 폴더 이름을 바탕으로 S3에서 프로젝트 폴더를 전부 다운로드 받아 각 CAD 파일의 정보를 추출하고 DB에 저장할 것이다.
1번을 통해 저장된 CAD 파일의 정보를 Jpa를 사용해 검색하는 단순 기능이다.
package com.cad.searh_service.entity;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class Cad {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String author;
@Column(name = "maincategory")
private String mainCategory;
@Column(name = "subcategory")
private String subCategory;
private String title;
@Column(name = "cadindex")
private String index;
@Column(name = "imgurl")
private String imgUrl;
@Column(name = "createdat")
private LocalDateTime createdAt;
@Column(name = "updatedat")
private LocalDateTime updatedAt;
}
위의 코드는 Cad Entity를 표현한 것이다. @Entity 어노테이션은 Cad class를 DB의 cad 테이블과 매핑해준다. @Column 어노테이션이 필드는 mysql이 index를 예약어로 사용하거나, 대문자를 지원하지 않는 등의 오류를 막기 위해 사용되었다.
package com.cad.searh_service.dto.cadDto;
@AllArgsConstructor
@Getter
public class CadSaveRequest {
private String s3Url;
private String author;
}
클라이언트로 부터 CAD파일 저장 요청을 받으면 Request Body에 s3Url, author 이 두개의 key가 담긴다.
package com.cad.searh_service.dto.cadDto;
@AllArgsConstructor
@Getter
public class CadSearchResponse {
private String author;
private String mainCategory;
private String subCategory;
private String title;
private String index;
private String s3Url;
private String updatedAt;
}
Cad Entity에서 id 필드를 제거한 클래스다. 검색 요청이 들어왔을 때 Controller에서 service로의 값 전달을 위해 사용된다.
package com.cad.searh_service.repository;
public interface CadRepository extends JpaRepository<Cad, Long> {
List<Cad> findByMainCategoryContainingOrSubCategoryContainingOrTitleContainingOrIndexContaining(String mainCategory, String subCategory, String title, String index);
}
JpaRepository를 상속 받아서 사용한다. 검색 기능은 옵션에 가까워서 간단하게 구현하였다.
package com.cad.searh_service.service;
@Service
@RequiredArgsConstructor
public class CadService {
private final CadRepository cadRepository;
private final S3Util s3Util;
private final AsposeUtil asposeUtil;
public void saveCadFile(CadSaveRequest request) {
String folder = request.getS3Url();
String author = request.getAuthor();
LocalDateTime dateTime = LocalDateTime.now();
s3Util.downloadFolder(folder);
Map<String, String[]> cadInfo = asposeUtil.getCadInfo(folder);
cadInfo.forEach((key, value) -> {
String imgUrl = s3Util.encryptImgUrl(value[2]);
cadRepository.save(
Cad.builder()
.author(author)
.mainCategory(folder)
.subCategory(value[0])
.title(value[1])
.index(key)
.imgUrl(imgUrl)
.createdAt(dateTime)
.updatedAt(dateTime)
.build()
);
});
}
public List<Cad> searchCadFile(String searchTerm) {
return cadRepository.findByMainCategoryContainingOrSubCategoryContainingOrTitleContainingOrIndexContaining(searchTerm, searchTerm, searchTerm, searchTerm);
}
}
생성자 주입을 사용해서 S3Util과 AsposeUtil Class의 메서드들을 사용헀다. 각 Class를 살펴보자
package com.cad.searh_service.util;
import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.services.s3.transfer.MultipleFileDownload;
import com.amazonaws.services.s3.transfer.TransferManager;
import com.amazonaws.services.s3.transfer.TransferProgress;
import com.cad.searh_service.controller.CadController;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
//import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.DecimalFormat;
import java.util.Base64;
@Component
@RequiredArgsConstructor
public class S3Util {
private final Logger log = LoggerFactory.getLogger(CadController.class);
private final TransferManager transferManager;
private final AmazonS3Client s3Client;
@Value("${cloud.aws.s3.bucket}")
public String bucket;
@Value("${cloud.aws.s3.algorithm}")
public String algorithm;
@Value("${cloud.aws.s3.key}")
public String key;
public void downloadFolder(String dir) {
try {
File s3Dir = new File("s3-download");
String tmp = URLDecoder.decode(dir, StandardCharsets.UTF_8);
MultipleFileDownload download = transferManager.downloadDirectory(bucket, tmp, s3Dir);
DecimalFormat decimalFormat = new DecimalFormat("##0.00");
while (!download.isDone()) {
TransferProgress progress = download.getProgress();
double percent = progress.getPercentTransferred();
System.out.println("[ download ]" + decimalFormat.format(percent) + "% download progressing ...");
}
} catch (AmazonServiceException e) {
log.error("Amazon service exception: ", e);
}
}
public String uploadImg(String title, ByteArrayOutputStream outputStream) {
try {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(outputStream.size());
ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
title = title.replace(".dwg", ".jpeg");
PutObjectRequest request = new PutObjectRequest(bucket, title, inputStream, metadata);
s3Client.putObject(request);
String pathUrl = s3Client.getUrl(bucket, title).toString();
outputStream.close();
inputStream.close();
return pathUrl;
} catch (IOException e) {
log.error("Stream IOException: ", e);
return null;
}
}
public String encryptImgUrl(String imgUrl) {
try {
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), algorithm);
Cipher cipher = Cipher.getInstance(algorithm);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
byte[] encryptedBytes = cipher.doFinal(imgUrl.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
log.error("Cipher getInstance error: ", e);
} catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) {
log.error("Cipher doFinal error: ", e);
}
return "";
}
}
로그 기록과 파일 전송, S3에 저장된 파일의 다운로드를 위해 constructor-based-injection을 사용해주자
private final Logger log = LoggerFactory.getLogger(CadController.class); private final TransferManager transferManager; private final AmazonS3Client s3Client;
application.properties에 적은 정보들을 가져오기 위해 @Value 어노테이션을 사용하자
application.properties에는 아래와 같이 적혀 있다.
cloud.aws.s3.bucket=dwg-upload cloud.aws.s3.algorithm=AES cloud.aws.s3.key=testingtestingtestingtestingtest
@Value("${cloud.aws.s3.bucket}") public String bucket; @Value("${cloud.aws.s3.algorithm}") public String algorithm; @Value("${cloud.aws.s3.key}") public String key;
본 함수는 인자로 s3 버킷에 저장된 폴더 이름을 전달 받아 폴더를 다운로드한다.
public void downloadFolder(String dir) { try { File s3Dir = new File("s3-download"); String tmp = URLDecoder.decode(dir, StandardCharsets.UTF_8); MultipleFileDownload download = transferManager.downloadDirectory(bucket, tmp, s3Dir); DecimalFormat decimalFormat = new DecimalFormat("##0.00"); while (!download.isDone()) { TransferProgress progress = download.getProgress(); double percent = progress.getPercentTransferred(); System.out.println("[ download ]" + decimalFormat.format(percent) + "% download progressing ..."); } } catch (AmazonServiceException e) { log.error("Amazon service exception: ", e); } }
- 우선 파일이 저장될 로컬 폴더 경로를 설정해준다.
- 버킷 내에 다운로드 하고 싶은 폴더의 이름을 UTF_8 방식으로 디코딩한다.
- transferManager의 downloadDirectory 메서드를 사용해 다운로드를 진행한다. 여기에는 세개의 인자가 필요한데 순서대로 S3 버킷 이름, 버킷 내 폴더 이름, 저장할 로컬 경로 순이다.
- MultipleFileDownload 객체의 getProgress() 메서드를 통해 다운로드 상태를 확인할 수 있다.
본 함수는 인자로 S3 버킷에 업로드 할 이미지의 이름과 ByteArrayOutputStream으로 변환된 이미지 파일을 받아 이미지를 S3 버킷에 업로드한다.
public String uploadImg(String title, ByteArrayOutputStream outputStream) { try { ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(outputStream.size()); ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray()); title = title.replace(".dwg", ".jpeg"); PutObjectRequest request = new PutObjectRequest(bucket, title, inputStream, metadata); s3Client.putObject(request); String pathUrl = s3Client.getUrl(bucket, title).toString(); outputStream.close(); inputStream.close(); return pathUrl; } catch (IOException e) { log.error("Stream IOException: ", e); return null; } }
- ObjectMetadata 객체를 만들어 output stream size를 metadata의 ContentLength로 설정한다. S3에 업로드 하기 위해서는 업로드 될 metadata의 ContentLength가 필요하다.
- ByteOutputStream으로부터 ByteArrayInputStream을 만든다.
- 업로드 하고 싶은 stream data를 PutObjectRequest object로 변환한다.
- S3Client의 PutObject 메서드를 사용해 object를 업로드 한다. 이를 위해서 2번 그리고 3번 과정이 필요했던 것이다.
- 스트림 닫아주자.
본 함수는 인자로 s3 버킷에 저장된 이미지의 url을 받아 암호화 한다.
public String encryptImgUrl(String imgUrl) { try { SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), algorithm); Cipher cipher = Cipher.getInstance(algorithm); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); byte[] encryptedBytes = cipher.doFinal(imgUrl.getBytes()); return Base64.getEncoder().encodeToString(encryptedBytes); } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { log.error("Cipher getInstance error: ", e); } catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException e) { log.error("Cipher doFinal error: ", e); } return ""; }
프론트에서 암호화된 이미지 url을 복호화 해 이미지를 사용자들에게 보여주어야 하기 때문에 대칭 암호화 알고리즘을 사용했다. 이 부분은 팀원이 구현한 부분이라 짧게만 설명하겠다.
JCA(Java Cryptography Architecture)를 사용하여 암호화를 수행한다.
- 바이트 배열과 알고리즘을 사용해서 secret key를 만든다.
- cipher 객체를 생성하고 초기화 한다.
- doFinal 메서드를 호출해 전달 받은 Img url을 암호화 한다.
- BASE64로 인코딩된 string을 반환한다.
package com.cad.searh_service.util;
import com.aspose.cad.Image;
import com.aspose.cad.fileformats.cad.CadImage;
import com.aspose.cad.fileformats.cad.cadconsts.CadEntityTypeName;
import com.aspose.cad.fileformats.cad.cadobjects.CadBaseEntity;
import com.aspose.cad.fileformats.cad.cadobjects.CadBlockEntity;
import com.aspose.cad.fileformats.cad.cadobjects.CadMText;
import com.aspose.cad.fileformats.cad.cadobjects.CadText;
import com.aspose.cad.imageoptions.CadRasterizationOptions;
import com.aspose.cad.imageoptions.JpegOptions;
import com.cad.searh_service.controller.CadController;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@RequiredArgsConstructor
@Component
public class AsposeUtil {
private final S3Util s3Util;
private final Logger log = LoggerFactory.getLogger(CadController.class);
private static final String cadDir = System.getProperty("user.home") + File.separator + "cad" + File.separator;
public Map<String, String[]> getCadInfo(String project) {
try {
Map<String, String[]> cadInfo = new HashMap<>();
Files.walkFileTree(Paths.get(cadDir + project), new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (!Files.isDirectory(file) && file.getFileName().toString().contains(".dwg")) {
String title = file.getFileName().toString();
String path = file.toAbsolutePath().toString();
String index = extractCadIndex(path);
ByteArrayOutputStream stream = convertCadToJpeg(path);
String s3Url = s3Util.uploadImg(title, stream);
path = path.substring(path.indexOf(project) + project.length(), path.indexOf(title) -1);
cadInfo.put(index, new String[] {path, title, s3Url});
}
return FileVisitResult.CONTINUE;
}
});
return cadInfo;
} catch (IOException e) {
log.error("getCadInfo IOException: ", e);
return null;
}
}
private ByteArrayOutputStream convertCadToJpeg(String cad) {
Image image = Image.load(cad);
CadRasterizationOptions options = new CadRasterizationOptions();
options.setPageHeight(200);
options.setPageWidth(200);
JpegOptions jpegOptions = new JpegOptions();
ByteArrayOutputStream stream = new ByteArrayOutputStream();
jpegOptions.setVectorRasterizationOptions(options);
image.save(stream, jpegOptions);
return stream;
}
private String extractCadIndex(String cad) {
try {
Set<String> index = new HashSet<>();
CadImage cadImage = (CadImage) CadImage.load(cad);
for (CadBlockEntity blockEntity : cadImage.getBlockEntities().getValues()) {
for (CadBaseEntity entity : blockEntity.getEntities()) {
if (entity.getTypeName() == CadEntityTypeName.TEXT) {
CadText cadText = (CadText) entity;
index.add(filterCadIndex(cadText.getDefaultValue()));
}
else if (entity.getTypeName() == CadEntityTypeName.MTEXT) {
CadMText cadMText = (CadMText) entity;
index.add(filterCadIndex(cadMText.getText()));
}
}
}
return String.join(" | ", index);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private String filterCadIndex(String index) {
String filtered = index.replaceAll(" ", "");
int numCnt = (int) filtered.chars().filter(c -> c >= '0' && c <= '9').count();
if (numCnt >= filtered.length() / 2)
return "";
return filtered;
}
}
본 함수는 앞선 게시물에서 이미 다룬적이 있다. S3Client를 통해 다운로드 받은 파일들이 저장된 폴더의 위치를 인자로 전달받아 폴더 내에 있는 모든 도면 파일을 찾아 각 파일의 텍스트 데이터를 추출하고 도면 파일을 이미지로 변환하는 함수이다.
public Map<String, String[]> getCadInfo(String project) { try { Map<String, String[]> cadInfo = new HashMap<>(); Files.walkFileTree(Paths.get(cadDir + project), new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (!Files.isDirectory(file) && file.getFileName().toString().contains(".dwg")) { String title = file.getFileName().toString(); String path = file.toAbsolutePath().toString(); String index = extractCadIndex(path); ByteArrayOutputStream stream = convertCadToJpeg(path); String s3Url = s3Util.uploadImg(title, stream); path = path.substring(path.indexOf(project) + project.length(), path.indexOf(title) -1); cadInfo.put(index, new String[] {path, title, s3Url}); } return FileVisitResult.CONTINUE; } }); return cadInfo; } catch (IOException e) { log.error("getCadInfo IOException: ", e); return null; } }
코드에 궁금한 것이 있다면 앞선 게시물을 참고하길 바란다.
본 함수는 캐드 파일(.dwg)에서 텍스트 데이터를 추출하는 함수다.
private String extractCadIndex(String cad) { try { Set<String> index = new HashSet<>(); CadImage cadImage = (CadImage) CadImage.load(cad); for (CadBlockEntity blockEntity : cadImage.getBlockEntities().getValues()) { for (CadBaseEntity entity : blockEntity.getEntities()) { if (entity.getTypeName() == CadEntityTypeName.TEXT) { CadText cadText = (CadText) entity; index.add(filterCadIndex(cadText.getDefaultValue())); } else if (entity.getTypeName() == CadEntityTypeName.MTEXT) { CadMText cadMText = (CadMText) entity; index.add(filterCadIndex(cadMText.getText())); } } } return String.join(" | ", index); } catch (Exception e) { e.printStackTrace(); } return null; }
Aspose의 java library를 사용하는 방법은 여러 차례 다뤘기 때문에 넘어가겠다.
본 함수 또한 Aspose의 java library를 사용하는 방법이지만 도면 파일에서 변환된 이미지 파일을 로컬에 저장하는 것보다. 바이트 스트림으로 바로 S3버킷에 저장하는 것이 더 빠를 것이라고 생각해 아래와 같이 구현하였다.
private ByteArrayOutputStream convertCadToJpeg(String cad) { Image image = Image.load(cad); CadRasterizationOptions options = new CadRasterizationOptions(); options.setPageHeight(200); options.setPageWidth(200); JpegOptions jpegOptions = new JpegOptions(); ByteArrayOutputStream stream = new ByteArrayOutputStream(); jpegOptions.setVectorRasterizationOptions(options); image.save(stream, jpegOptions); return stream; }
본 함수는 추출한 텍스트 데이터를 간단히 전처리 해주는 함수이다. 라이브러리를 사용해서 데이터를 추출하면 공백이 너무 많고 당장은 필요 없는 수치 정보가 많이 포함되어서 다 제거해 주었다.
private String filterCadIndex(String index) { String filtered = index.replaceAll(" ", ""); int numCnt = (int) filtered.chars().filter(c -> c >= '0' && c <= '9').count(); if (numCnt >= filtered.length() / 2) return ""; return filtered; }
package com.cad.searh_service.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/cad")
public class CadController {
private final CadService cadService;
private final Logger log = LoggerFactory.getLogger(CadController.class);
@GetMapping("/data")
public ResponseEntity<List<Cad>> searchCad(@RequestParam String searchTerm) {
try {
if (searchTerm.isEmpty())
throw new IllegalArgumentException("Parameter is not entered.");
List<Cad> result = cadService.searchCadFile(searchTerm);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
log.error("Invalid input parameter: " + searchTerm, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
} catch (Exception e) {
log.error("Internal server error: ", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/data")
public ResponseEntity<Void> saveCad(@RequestBody CadSaveRequest request) {
try {
if (request == null)
throw new IllegalArgumentException("RequestBody is not entered");
cadService.saveCadFile(request);
return ResponseEntity.ok().build();
} catch (IllegalArgumentException e) {
log.error("Invalid input parameter: ", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
} catch (Exception e) {
log.error("Internal server error: ", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
컨트롤러는 저장과 검색 두가지 기능만을 지원한다.