ソースを参照

add gzip compression

Daniel Bohry 1 ヶ月 前
コミット
4aca99752c

+ 11 - 6
src/main/java/com/lhamacorp/knotes/api/NoteController.java

@@ -1,6 +1,8 @@
 package com.lhamacorp.knotes.api;
 
 import com.lhamacorp.knotes.api.dto.NoteRequest;
+import com.lhamacorp.knotes.api.dto.NoteResponse;
+import com.lhamacorp.knotes.api.dto.NoteUpdateRequest;
 import com.lhamacorp.knotes.domain.Note;
 import com.lhamacorp.knotes.service.NoteService;
 import org.springframework.http.ResponseEntity;
@@ -18,18 +20,21 @@ public class NoteController {
     }
 
     @GetMapping("{id}")
-    public ResponseEntity<Note> find(@PathVariable String id) {
-        return ResponseEntity.ok().body(service.findById(id));
+    public ResponseEntity<NoteResponse> find(@PathVariable String id) {
+        Note note = service.findById(id);
+        return ResponseEntity.ok().body(NoteResponse.from(note));
     }
 
     @PostMapping
-    public ResponseEntity<Note> save(@RequestBody NoteRequest request) {
-        return ResponseEntity.ok().body(service.save(request.note()));
+    public ResponseEntity<NoteResponse> save(@RequestBody NoteRequest request) {
+        Note savedNote = service.save(request.note());
+        return ResponseEntity.ok().body(NoteResponse.from(savedNote));
     }
 
     @PutMapping
-    public ResponseEntity<Note> update(@RequestBody Note note) {
-        return ResponseEntity.ok().body(service.update(note.id(), note.content()));
+    public ResponseEntity<NoteResponse> update(@RequestBody NoteUpdateRequest request) {
+        Note updatedNote = service.update(request.id(), request.content());
+        return ResponseEntity.ok().body(NoteResponse.from(updatedNote));
     }
 
 }

+ 17 - 0
src/main/java/com/lhamacorp/knotes/api/dto/NoteResponse.java

@@ -0,0 +1,17 @@
+package com.lhamacorp.knotes.api.dto;
+
+import com.lhamacorp.knotes.domain.Note;
+
+import java.time.Instant;
+
+public record NoteResponse(String id, String content, Instant createdAt, Instant modifiedAt) {
+
+    public static NoteResponse from(Note note) {
+        return new NoteResponse(
+            note.id(),
+            note.content(),
+            note.createdAt(),
+            note.modifiedAt()
+        );
+    }
+}

+ 4 - 0
src/main/java/com/lhamacorp/knotes/api/dto/NoteUpdateRequest.java

@@ -0,0 +1,4 @@
+package com.lhamacorp.knotes.api.dto;
+
+public record NoteUpdateRequest(String id, String content) {
+}

+ 14 - 1
src/main/java/com/lhamacorp/knotes/domain/Note.java

@@ -1,10 +1,23 @@
 package com.lhamacorp.knotes.domain;
 
+import com.lhamacorp.knotes.util.CompressionUtils;
+import org.bson.types.Binary;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.mongodb.core.mapping.Document;
 
 import java.time.Instant;
 
 @Document("notes")
-public record Note(@Id String id, String content, Instant createdAt, Instant modifiedAt) {
+public record Note(@Id String id, Binary compressedContent, Instant createdAt, Instant modifiedAt) {
+
+    public Note(String id, String content, Instant createdAt, Instant modifiedAt) {
+        this(id, content != null ? new Binary(CompressionUtils.compress(content)) : null, createdAt, modifiedAt);
+    }
+
+    public String content() {
+        if (compressedContent == null) {
+            return null;
+        }
+        return CompressionUtils.decompress(compressedContent.getData());
+    }
 }

+ 79 - 0
src/main/java/com/lhamacorp/knotes/util/CompressionUtils.java

@@ -0,0 +1,79 @@
+package com.lhamacorp.knotes.util;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Utility class for GZIP compression and decompression of text content.
+ * Provides transparent compression for note content to reduce database storage size.
+ */
+public class CompressionUtils {
+
+    /**
+     * Compresses a string using GZIP compression.
+     *
+     * @param input the string to compress
+     * @return compressed byte array, or null if input is null/empty
+     * @throws RuntimeException if compression fails
+     */
+    public static byte[] compress(String input) {
+        if (input == null || input.isEmpty()) {
+            return new byte[0];
+        }
+
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+             GZIPOutputStream gzip = new GZIPOutputStream(baos)) {
+
+            gzip.write(input.getBytes(StandardCharsets.UTF_8));
+            gzip.close();
+
+            return baos.toByteArray();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to compress text content", e);
+        }
+    }
+
+    /**
+     * Decompresses a GZIP compressed byte array back to a string.
+     *
+     * @param compressed the compressed byte array
+     * @return decompressed string, or empty string if input is null/empty
+     * @throws RuntimeException if decompression fails
+     */
+    public static String decompress(byte[] compressed) {
+        if (compressed == null || compressed.length == 0) {
+            return "";
+        }
+
+        try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed);
+             GZIPInputStream gzip = new GZIPInputStream(bais);
+             ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+
+            byte[] buffer = new byte[1024];
+            int len;
+            while ((len = gzip.read(buffer)) > 0) {
+                baos.write(buffer, 0, len);
+            }
+
+            return baos.toString(StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to decompress text content", e);
+        }
+    }
+
+    /**
+     * Calculates the compression ratio as a percentage.
+     *
+     * @param originalSize   original content size in bytes
+     * @param compressedSize compressed content size in bytes
+     * @return compression ratio (e.g., 0.75 means 75% reduction)
+     */
+    public static double getCompressionRatio(int originalSize, int compressedSize) {
+        if (originalSize == 0) return 0.0;
+        return 1.0 - ((double) compressedSize / originalSize);
+    }
+}

+ 59 - 0
src/test/java/com/lhamacorp/knotes/domain/NoteTest.java

@@ -0,0 +1,59 @@
+package com.lhamacorp.knotes.domain;
+
+import org.junit.jupiter.api.Test;
+import java.time.Instant;
+import static org.junit.jupiter.api.Assertions.*;
+
+class NoteTest {
+
+    @Test
+    void constructor_withStringContent_shouldCompressAndStore() {
+        // Given
+        String id = "test-id";
+        String content = "This is a test note with some content that should be compressed.";
+        Instant now = Instant.now();
+
+        // When
+        Note note = new Note(id, content, now, now);
+
+        // Then
+        assertEquals(id, note.id());
+        assertEquals(content, note.content()); // Should decompress correctly
+        assertEquals(now, note.createdAt());
+        assertEquals(now, note.modifiedAt());
+
+        // Verify that content is actually stored compressed
+        assertNotNull(note.compressedContent());
+        assertTrue(note.compressedContent().getData().length > 0);
+    }
+
+    @Test
+    void constructor_withNullContent_shouldHandleGracefully() {
+        // Given
+        String id = "test-id";
+        Instant now = Instant.now();
+
+        // When
+        Note note = new Note(id, (String) null, now, now);
+
+        // Then
+        assertEquals(id, note.id());
+        assertNull(note.content());
+        assertNull(note.compressedContent());
+    }
+
+    @Test
+    void content_withCompressedContent_shouldDecompressCorrectly() {
+        // Given
+        String originalContent = "Test note content for compression verification.";
+        String id = "test-id";
+        Instant now = Instant.now();
+
+        // When
+        Note note = new Note(id, originalContent, now, now);
+        String retrievedContent = note.content();
+
+        // Then
+        assertEquals(originalContent, retrievedContent);
+    }
+}

+ 228 - 0
src/test/java/com/lhamacorp/knotes/util/CompressionUtilsTest.java

@@ -0,0 +1,228 @@
+package com.lhamacorp.knotes.util;
+
+import org.junit.jupiter.api.Test;
+import java.nio.charset.StandardCharsets;
+import static org.junit.jupiter.api.Assertions.*;
+
+class CompressionUtilsTest {
+
+    @Test
+    void compressAndDecompress_shouldReturnOriginalText() {
+        // Given
+        String originalText = "This is a test note with some content that should compress well. " +
+                             "This is repeated text. This is repeated text. This is repeated text.";
+
+        // When
+        byte[] compressed = CompressionUtils.compress(originalText);
+        String decompressed = CompressionUtils.decompress(compressed);
+
+        // Then
+        assertEquals(originalText, decompressed);
+        assertTrue(compressed.length < originalText.getBytes().length,
+                  "Compressed size should be smaller than original");
+    }
+
+    @Test
+    void compress_withNullInput_shouldReturnEmptyArray() {
+        // When
+        byte[] result = CompressionUtils.compress(null);
+
+        // Then
+        assertNotNull(result);
+        assertEquals(0, result.length);
+    }
+
+    @Test
+    void compress_withEmptyInput_shouldReturnEmptyArray() {
+        // When
+        byte[] result = CompressionUtils.compress("");
+
+        // Then
+        assertNotNull(result);
+        assertEquals(0, result.length);
+    }
+
+    @Test
+    void decompress_withNullInput_shouldReturnEmptyString() {
+        // When
+        String result = CompressionUtils.decompress(null);
+
+        // Then
+        assertEquals("", result);
+    }
+
+    @Test
+    void decompress_withEmptyInput_shouldReturnEmptyString() {
+        // When
+        String result = CompressionUtils.decompress(new byte[0]);
+
+        // Then
+        assertEquals("", result);
+    }
+
+    @Test
+    void getCompressionRatio_shouldCalculateCorrectly() {
+        // Given
+        String text = "This is some text that will be compressed for testing purposes. " +
+                     "Repeated content. Repeated content. Repeated content.";
+
+        // When
+        byte[] compressed = CompressionUtils.compress(text);
+        double ratio = CompressionUtils.getCompressionRatio(
+            text.getBytes().length,
+            compressed.length
+        );
+
+        // Then
+        assertTrue(ratio > 0.0, "Should have some compression ratio");
+        assertTrue(ratio < 1.0, "Compression ratio should be less than 1.0");
+
+        System.out.println("Basic Text Test:");
+        System.out.println("Original size: " + text.getBytes().length + " bytes");
+        System.out.println("Compressed size: " + compressed.length + " bytes");
+        System.out.println("Compression ratio: " + String.format("%.2f%%", ratio * 100));
+    }
+
+    @Test
+    void compress_largeRepeatingText_shouldAchieveHighCompressionRatio() {
+        // Given - Large text with lots of repetition (typical of notes with repeated patterns)
+        StringBuilder sb = new StringBuilder();
+        String pattern = "This is a repeated pattern in a note that demonstrates compression. ";
+        for (int i = 0; i < 50; i++) {
+            sb.append(pattern);
+        }
+        String largeText = sb.toString();
+
+        // When
+        byte[] compressed = CompressionUtils.compress(largeText);
+        String decompressed = CompressionUtils.decompress(compressed);
+        double ratio = CompressionUtils.getCompressionRatio(largeText.getBytes().length, compressed.length);
+
+        // Then
+        assertEquals(largeText, decompressed, "Decompressed text should match original");
+        assertTrue(ratio > 0.8, "Should achieve high compression ratio for repetitive text");
+
+        System.out.println("\nLarge Repetitive Text Test:");
+        System.out.println("Original size: " + largeText.getBytes().length + " bytes");
+        System.out.println("Compressed size: " + compressed.length + " bytes");
+        System.out.println("Compression ratio: " + String.format("%.2f%%", ratio * 100));
+        System.out.println("Size reduction: " + (largeText.getBytes().length - compressed.length) + " bytes saved");
+    }
+
+    @Test
+    void compress_jsonLikeContent_shouldCompressWell() {
+        // Given - JSON-like content (common in technical notes)
+        String jsonContent = """
+            {
+              "project": "kNotes",
+              "technologies": ["Java", "Spring Boot", "MongoDB", "JavaScript"],
+              "features": {
+                "compression": true,
+                "dark_mode": true,
+                "auto_save": true
+              },
+              "description": "A note-taking application with GZIP compression",
+              "metadata": {
+                "created": "2024",
+                "author": "Developer",
+                "version": "1.0"
+              }
+            }
+            """.repeat(10); // Repeat to make it larger
+
+        // When
+        byte[] compressed = CompressionUtils.compress(jsonContent);
+        String decompressed = CompressionUtils.decompress(compressed);
+        double ratio = CompressionUtils.getCompressionRatio(jsonContent.getBytes().length, compressed.length);
+
+        // Then
+        assertEquals(jsonContent, decompressed);
+        assertTrue(ratio > 0.5, "JSON content should compress well due to repeated patterns");
+
+        System.out.println("\nJSON-like Content Test:");
+        System.out.println("Original size: " + jsonContent.getBytes().length + " bytes");
+        System.out.println("Compressed size: " + compressed.length + " bytes");
+        System.out.println("Compression ratio: " + String.format("%.2f%%", ratio * 100));
+    }
+
+    @Test
+    void compress_codeSnippet_shouldPreserveFormatting() {
+        // Given - Code snippet (common content in developer notes)
+        String codeSnippet = """
+            public class Example {
+                private String value;
+
+                public Example(String value) {
+                    this.value = value;
+                }
+
+                public String getValue() {
+                    return this.value;
+                }
+
+                public void setValue(String value) {
+                    this.value = value;
+                }
+
+                @Override
+                public String toString() {
+                    return "Example{value='" + value + "'}";
+                }
+            }
+            """;
+
+        // When
+        byte[] compressed = CompressionUtils.compress(codeSnippet);
+        String decompressed = CompressionUtils.decompress(compressed);
+
+        // Then
+        assertEquals(codeSnippet, decompressed, "Code formatting should be preserved exactly");
+        assertTrue(compressed.length < codeSnippet.getBytes().length, "Code should still compress");
+
+        System.out.println("\nCode Snippet Test:");
+        System.out.println("Original size: " + codeSnippet.getBytes().length + " bytes");
+        System.out.println("Compressed size: " + compressed.length + " bytes");
+        System.out.println("Compression ratio: " + String.format("%.2f%%",
+            CompressionUtils.getCompressionRatio(codeSnippet.getBytes().length, compressed.length) * 100));
+    }
+
+    @Test
+    void compress_mixedContent_shouldHandleVariousCharacters() {
+        // Given - Mixed content with various characters (emoji, unicode, special chars)
+        String mixedContent = """
+            📝 Note Title: Project Planning
+
+            ✅ Tasks completed:
+            • Database setup ✓
+            • API endpoints ✓
+            • Frontend components ✓
+
+            🔄 In Progress:
+            • Testing & validation
+            • Performance optimization
+            • Documentation updates
+
+            💡 Ideas for improvement:
+            - Add real-time collaboration
+            - Implement version history
+            - Mobile app development
+
+            Special characters: àáâãäåæçèéêë ñ ø ß ü ÿ
+            Math symbols: α β γ δ ∑ ∫ ∆ π ∞
+            """;
+
+        // When
+        byte[] compressed = CompressionUtils.compress(mixedContent);
+        String decompressed = CompressionUtils.decompress(compressed);
+
+        // Then
+        assertEquals(mixedContent, decompressed, "Mixed content with unicode should be preserved");
+        assertTrue(compressed.length < mixedContent.getBytes(StandardCharsets.UTF_8).length, "Should still achieve compression");
+
+        System.out.println("\nMixed Content with Unicode Test:");
+        System.out.println("Original size: " + mixedContent.getBytes().length + " bytes");
+        System.out.println("Compressed size: " + compressed.length + " bytes");
+        System.out.println("Compression ratio: " + String.format("%.2f%%",
+            CompressionUtils.getCompressionRatio(mixedContent.getBytes().length, compressed.length) * 100));
+    }
+}