sharingBookReview-SERVICE / sharingBookReview-BE

Diver provides collections and reviews of books and users can share them like a social network.
1 stars 1 forks source link
es6 javascript mongodb nodejs

🌊 DIVER BACKEND 🌊

❓ Business Model - Diver provides collections and reviews of books and users can share them like in a social network.

🌌 Base - A Node.js + Express based backend project.

πŸ”— Our frontend - Click Frontend side to go to corresponding React.js based frontend project.


Table of Contents / λͺ©μ°¨

  1. Goal Β· λͺ©ν‘œ
  2. Architecture · ꡬ쑰
  3. Main features Β· μ£Όμš” κΈ°λŠ₯
  4. Sample Codes Β· μƒ˜ν”Œ μ½”λ“œ
  5. Dependencies Β· μ˜μ‘΄μ„±
  6. Contributors Β· 인원

1. Goal Β· λͺ©ν‘œ πŸ₯…

At the beginning of the project, deciding which part to focus and which part to discard – in terms of the tech stack – was the most difficult task.

During previous projects and tutorials, we could glimpse several basic techs including Application, Database, DevOps and/or Business tools.

Yet, with not enough time to master everything that we learned, here are the things that we wanted to put an emphasis on.


ν”„λ‘œμ νŠΈλ₯Ό μ‹œμž‘ν•  λ•Œ μ–΄λ–€ 기술 μŠ€νƒμ— μ§‘μ€‘ν•˜κ³  μ–΄λ–€ 것을 μ œμ³λ‘˜μ§€ μ •ν•˜λŠ” 것이 κ°€μž₯ μ–΄λ €μ› μŠ΅λ‹ˆλ‹€.

μ΄μ „μ˜ ν”„λ‘œμ νŠΈλ“€κ³Ό νŠœν† λ¦¬μ–Όμ—μ„œ λͺ‡κ°€μ§€ μ–΄ν”Œλ¦¬μΌ€μ΄μ…˜, λ°μ΄ν„°λ² μ΄μŠ€, 데브옡슀 그리고 λΉ„μ¦ˆλ‹ˆμŠ€ νˆ΄λ“€μ„ μ ‘ν•  수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€λ§Œ,

배운 것듀을 λͺ¨λ‘ μˆ™λ‹¬ν•˜κΈ°μ—λŠ” 짧은 μ‹œκ°„μ΄μ—ˆκΈ° λ•Œλ¬Έμ— μ•„λž˜μ˜ λͺ©λ‘μ— μ£Όμ•ˆμ μ„ λ‘κΈ°λ‘œ ν•˜μ˜€μŠ΅λ‹ˆλ‹€.


2. Architecture Β· ꡬ쑰 πŸ‘·

.
β”œβ”€β”€ ...
β”œβ”€β”€ controllers
|   β”œβ”€β”€ book.controller.js
|   β”œβ”€β”€ collection.controller.js
|   β”œβ”€β”€ crawl.controller.js
|   β”œβ”€β”€ get_collection_image.js
|   β”œβ”€β”€ get_trending_review.js
|   β”œβ”€β”€ image_upload.controller.js
|   β”œβ”€β”€ review.controller.js
|   β”œβ”€β”€ schedule.controller.js
|   β”œβ”€β”€ super.controller.js
|   β”œβ”€β”€ tag.controller.js
|   └── utilities.js
|
β”œβ”€β”€ middleware
|   └── auth_middleware.js
|          
β”œβ”€β”€ models
|   β”œβ”€β”€ alert.js 
|   β”œβ”€β”€ book.js
|   β”œβ”€β”€ changes_index.js
|   β”œβ”€β”€ collection.js
|   β”œβ”€β”€ comment.js
|   β”œβ”€β”€ follow.js
|   β”œβ”€β”€ index.js
|   β”œβ”€β”€ review.js
|   β”œβ”€β”€ suggestion.js
|   β”œβ”€β”€ trend.js
|   β”œβ”€β”€ user.js
|   └── utilities.js
| 
β”œβ”€β”€ models
|   β”œβ”€β”€ alert.js 
|   β”œβ”€β”€ book.js
|   β”œβ”€β”€ changes_index.js
|   β”œβ”€β”€ collection.js
|   β”œβ”€β”€ comment.js
|   β”œβ”€β”€ follow.js
|   β”œβ”€β”€ index.js
|   β”œβ”€β”€ review.js
|   β”œβ”€β”€ suggestion.js
|   β”œβ”€β”€ trend.js
|   β”œβ”€β”€ user.js
|   └── utilities.js
| 
β”œβ”€β”€ routes
|   β”œβ”€β”€ book.js 
|   β”œβ”€β”€ collection.js
|   β”œβ”€β”€ comments.js
|   β”œβ”€β”€ feeds.js
|   β”œβ”€β”€ follow.js
|   β”œβ”€β”€ google_passport.js
|   β”œβ”€β”€ index.js
|   β”œβ”€β”€ kakao_passport.js
|   β”œβ”€β”€ review.js
|   β”œβ”€β”€ search.js
|   β”œβ”€β”€ suggestion.js
|   └── user.js
| 
β”œβ”€β”€ app.js
β”œβ”€β”€ config.js
β”œβ”€β”€ exp_list.js
β”œβ”€β”€ server.js
β”œβ”€β”€ socket.js
β”œβ”€β”€ ...

3. Main features Β· μ£Όμš” κΈ°λŠ₯ πŸ’‘

3.1 Basic Features

3.1 Feeds

Users can read others' reviews through the feed. Unlike ordinary projects with basic CRUD, **DIVER** provides a complex Read experience with following algorithm. The feed is consisted of 3 different stages, each offering a set of reviews based on different algorithm
Stage Within 7 days Followed User's review Likes
1. Recent unread reviews of following users. 🟒 🟒 ❌
2. Trending reviews (reviews with high trending point, in other words, recent review with lots of likes) 🟒 ❌ 🟒
3. All recent unread reviews regardless of following. 🟒 ❌ ❌
Extra: Show all reviews (Provided on a different tab) ❌ ❌ 🟒
```javascript // ./routes/feeds.js // ... router.get('/', authMiddleware(false), async (req, res, next) => { /** * Number of reviews to send per request. * @type {number} */ const userId = res.locals.user?._id try { // 0. Declare constants to query. const user = await User.findById(userId) /** * Array of ObjectId of read reviews of user. If user is null (i.e. not logged in) assigned as undefined. * @type {ObjectId[]} */ const readReviews = user?.read_reviews.map((element) => element._id) /** * Query statement for reviews: unread and created within one week * @type {Object} */ const query = { _id: { $nin: readReviews }, created_at: { $gte: new Date() - 1000 * 60 * 60 * 24 * 7 }, } // 1. Return recent unread reviews of following users. /** * Array of user IDs that the the user in parameter is following. * @type {ObjectId[]} */ const followees = (await Follow.find({ sender: userId })).map( (follow) => follow.receiver ) /** * Array of reviews of following users * @type {Document[]} */ const followingReviews = await Review.find({ ...query, user: { $in: [...followees, userId] }, }) .sort({ created_at: -1 }) .limit(SCROLL_SIZE) .populate({ path: 'user', select: '_id profileImage nickname' }) .populate({ path: 'book', select: '_id title author' }) // If no documents found with query, continue until next if statement. This keep goes on. if (followingReviews.length) { return res.json(await showLikeFollowBookMarkStatus(followingReviews, userId)) } // 2. Return trending reviews (reviews with high trending point, in other words, recent review with lots of likes) const trend = await Trend.findOne({}, {}, { sort: { created_at: -1 } }) const trendingReviewIdArr = trend.trendingReviews.map(review => review._id) const trendingReviews = await Review.find({ _id: { $in: trendingReviewIdArr, $nin: readReviews }, }) //todo: $sample λ„£κΈ° .limit(SCROLL_SIZE) .populate({ path: 'user', select: '_id profileImage nickname' }) .populate({ path: 'book', select: '_id title author' }) if (trendingReviews.length) { return res.json(await showLikeFollowBookMarkStatus(trendingReviews, userId)) } // 3. Return all recent unread reviews regardless of following. const recentReviews = await Review.find(query) .sort({ created_at: -1 }) .limit(SCROLL_SIZE) .populate({ path: 'user', select: '_id profileImage nickname' }) .populate({ path: 'book', select: '_id title author' }) if (recentReviews.length) { return res.json(await showLikeFollowBookMarkStatus(recentReviews, userId)) } // 4. If no reviews are found by all three queries. return res.sendStatus(204) } catch (e) { console.error(e) return next(new Error('ν”Όλ“œ 뢈러였기λ₯Ό μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.')) } }) // ... ```

3.2 Tags

1. Update topTags field of books everyday 2. Top 10 most used tags of one book is saved 3. When writing reviews, exposes the top 10 frequent tags to users to reuse other users' tags ```js export default class tagController { static async updateTopTags() { console.log('updateTopTags λ₯Ό μ‹€ν–‰ν•©λ‹ˆλ‹€.') try { const updatedBooks = await tagController.#getUpdatedBooks() const updatedTags = await tagController.#updateTopTags(updatedBooks) const numOfUpdatedTags = await tagController.#updateTagCollection(updatedTags) console.log(`updateTopTags κ°€ μ„±κ³΅μ μœΌλ‘œ μ™„λ£Œλ˜μ—ˆμœΌλ©°, 총 ${numOfUpdatedTags} 개의 νƒœκ·Έκ°€ 생성 λ˜λŠ” μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`) } catch (err) { console.error(err) } } /** * Returns array of isbn of which updateOnTag field is true. And set updateOnTag field to false. * @returns {Promise[]>} Array of mongodb documents with id and reviews field. */ static async #getUpdatedBooks() { const books = await Book.find({ updateOnTag: true }, { reviews: 1, topTags: 1 }).populate({ path: 'reviews', select: 'hashtags', }) await Book.updateMany({ updateOnTag: true }, { updateOnTag: false }) return books } /** * For each book in books, calculate most used hashtags of its reviews. Then save them as topTags field in the book. * @param books Array of book documents. * @returns {Promise} Array of updated tags. */ static async #updateTopTags(books) { const updatedTags = new Set() const promises = books.map(book => { const allTagsOfOneBook = book.reviews.reduce((acc, review) => { if (!review.hashtags) return acc return [...acc, ...review.hashtags] }, []) const uniqueTagsOfOneBook = [...new Set(allTagsOfOneBook)] book.topTags = getMostUsedTagsForOneBook(allTagsOfOneBook, uniqueTagsOfOneBook) updatedTags.add(...uniqueTagsOfOneBook) return book.save() }) await Promise.allSettled(promises) return [...updatedTags] function getMostUsedTagsForOneBook(allTags, uniqueTags) { return uniqueTags.map((tag) => { return { name: tag, occurrence: allTags.filter((_tag) => tag === tag).length, } }).sort((a, b) => b.occurrence - a.occurrence).slice(0, 9).map((tag) => tag.name) } } /** * Update tag collection's contents field with books having the tag in its topTags field. * @param tags {string[]} * @returns {Promise} Number of tag documents that successfully updated. */ static async #updateTagCollection(tags) { const promises = tags.map(async tag => { const tagDocument = { name: tag, type: 'tag' } const collection = await Collection.findOne(tagDocument) ?? await Collection.create(tagDocument) const booksContainingTag = await Book.find({ topTags: tag }) collection.contents = booksContainingTag.map((book) => { return { book: book.isbn } }) return collection.save() }) const result = await Promise.allSettled(promises) return result.filter(res => res.status === 'fulfilled').length } } ```

4. Sample Codes: Improvements by Refactoring Β· μƒ˜ν”Œ μ½”λ“œ : λ¦¬νŒ©ν† λ§μ„ ν†΅ν•œ κ°œμ„ 

4.1 Javascript/ES6+ JavaScript

Not only using basics of ES6 superficially, we tried to implement syntactic sugars of recent versions of ECMAScript.

Here are some codes that went through refactoring to apply ES6+


ES6+ 의 기본뿐만 μ•„λ‹ˆλΌ μ΅œμ‹  버전 ECMAScript 의 문법적 섀탕을 μ μš©ν•˜κΈ° μœ„ν•΄μ„œ λ…Έλ ₯ν–ˆμŠ΅λ‹ˆλ‹€.

μ•„λž˜λŠ” ES6+ μ μš©μ„ ν•˜μ—¬ λ¦¬νŒ©ν† λ§μ„ μ§„ν–‰ν•œ μ½”λ“œμž…λ‹ˆλ‹€.


4.1.1 Promise.allSettled()

To reduce time consumption on crawling, we implemented Promise.allSettled().

Because we can query on anytime later, being rejected on some requests was not a big deal – and this DOES happen due to advertisement in source URL.


크둀링 μ‹œ μ‹œκ°„ μ†Œμš”λ₯Ό 쀄이기 μœ„ν•΄ Promise.allSettled() λ₯Ό μ‚¬μš©ν•˜μ˜€μŠ΅λ‹ˆλ‹€.

λ‚˜μ€‘μ—λΌλ„ λ‹€μ‹œ λ°›μ•„μ˜€λ©΄ 되기 λ•Œλ¬Έμ— λͺ‡κ°œ μ‹€νŒ¨ν•œλ‹€κ³  ν•˜λ”λΌλ„ 큰 λ¬Έμ œκ°€ μ•„λ‹ˆμ—ˆκΈ° λ•Œλ¬Έμž…λ‹ˆλ‹€. μ‹€νŒ¨λŠ” λŒ€μƒ νŽ˜μ΄μ§€μ— μ‚½μž…λœ 예기치 λͺ»ν•œ κ΄‘κ³  λ•Œλ¬Έμ΄μ—ˆμŠ΅λ‹ˆλ‹€.

// ./controllers/crawl.js
// todo Deprecated example

const getBestsellers = async () => {
    const BESTSELLER_URL = 'https://www.kyobobook.co.kr/bestSellerNew/bestseller.laf'
    const query = 'ul > input[name=barcode]'
    const page = await launchBrowserAndGotoURL(BESTSELLER_URL)

    const isbnArr = await page.$$eval(query, (inputList) => inputList.map((input) => input.value))
    await (await page.browser()).close()
    const top10 = isbnList.slice(0, 9)
    const promises = top10.map((isbn) => searchBooks('isbn', isbn))
    return [...await Promise.allSettled(promises)].filter((p) => p.status === 'fulfilled').map((p) => p.value)
}

4.1.2 Optional Chaining (?.)

Optional Chaining was powerful and simple operator to process optional parameters. It created more readable code than if statement.


μ˜΅μ…”λ„ 체이닝은 선택적인 λ§€κ°œλ³€μˆ˜λ₯Ό μ²˜λ¦¬ν•˜κΈ°μ— κ°•λ ₯ν•˜κ³  κ°„κ²°ν•œ μ—°μ‚°μžμž…λ‹ˆλ‹€.if문에 λΉ„ν•΄ 더 읽기 쒋은 μ½”λ“œκ°€ λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

// Saving a review with / without an image.

// Previously
let image
if (res.locals) image = res.locals.url
await Review.create({ content, quote, image })

// Refactored
const image = res.locals?.url
await Review.create({ content, quote, image })

4.1.3 Nullish Coalescing (??)

?? operator also helped to simply a complex if statement and reduce use of let.


?? μ—°μ‚°μž μ—­μ‹œ λ³΅μž‘ν•œ if문을 κ°„λ‹¨ν•˜κ²Œ ν•΄μ£Όκ³  let을 덜 μ‚¬μš©ν•˜κ²Œ ν•΄μ£Όμ—ˆμŠ΅λ‹ˆλ‹€.

// Find a collection document by its name.
// If such document doesn't exist, create one.

// Previously
const updateCollection = async (tag) => {
    let collection
    collection = await Collection.findOne({ name: tag, type: 'tag' })
    if (!collection) collection = await Collection.create({ name: tag, type: 'tag ' })

    //...
}

// Refactored
const updateCollection = async (tag) => {
    const collection = await Collection.findOne({ name: tag, type: 'tag' }) ?? await Collection.create({
        name: tag,
        type: 'tag'
    })

    //...
}

4.1.4 Async / Await

By using async/await, it was possible to avoid complex call backs and use try/catch to handle errors.


async/await을 μ‚¬μš©ν•˜μ—¬ λ³΅μž‘ν•œ 콜백 ꡬ쑰λ₯Ό ν”Όν•˜κ³  try/catch둜 μ—λŸ¬λ₯Ό μ²˜λ¦¬ν•  수 μžˆμ—ˆμŠ΅λ‹ˆλ‹€.

export default class ReviewController extends SuperController {
    //...
    static async apiDeleteReview(req, res, next) {
        const { _id: userId } = res.locals.user

        try {
            const { reviewId } = ReviewController._getIds(req)
            const review = await Review.findById(reviewId)

            if (!review) return next({ message: 'μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 리뷰 아이디 μž…λ‹ˆλ‹€.', status: 400 })
            ReviewController._validateAuthor(review.user, userId)

            await review.deleteOne()

            return res.sendStatus(202)
        } catch (err) {
            console.error(err)
            if (err.status) next(err)
            return next({ message: '리뷰 μ‚­μ œλ₯Ό μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.', status: 500 })
        }
    }
}

4.1.5 Import (ESModule)

By adopting ESModule, modules are loaded both asynchronously and partially. Thus saving memory and time.


ES λͺ¨λ“ˆμ„ μ‚¬μš©ν•˜μ—¬ λͺ¨λ“ˆμ„ λΉ„-동기적 그리고 λΆ€λΆ„μ μœΌλ‘œ λΆˆλŸ¬μ˜¨λ‹€. λ”°λΌμ„œ λ©”λͺ¨λ¦¬λ₯Ό 아끼고 속도λ₯Ό ν–₯μƒμ‹œν‚¬ 수 μžˆλ‹€.


4.1.6 Class

Object-oriented programming is possible with class in some degree. In Diver backend, both reviews and collections share common features: they need a user to be logged in to post, they are MongoDB documents, they are both related to book and so on.

Therefore, Super class inherits shared common static methods – which I wish to be protected methods but current JS doesn't support such feature – to ReviewController and CollectionController classes.

And controllers related to the same MongoDB collections – Review and Collection – are grouped into each class.

export default class SuperController {
    static _getIds(req) {
        //...
    }

    static _validateAuthor(author, currentUserId) {
        //...
    }
}

// review.controller.js
import SuperController from './super.controller.js'

export default class ReviewController extends SuperController {
    static async apiPostReview(req, res, next) {
        //...
    }

    //...
}

// routes/reviews.js
//...
import ReviewCtrl from './review.controller.js'

router.route('/')
    .post(upload.single('image'), ImageUpload.uploadImage, ReviewCtrl.apiPostReview)
    .get(ReviewCtrl.apiGetReviews)

router.route('/:reviewId')
    .get(ReviewCtrl.apiGetReview)
    .put(ReviewCtrl.apiPutReview)
    .delete(ReviewCtrl.apiDeleteReview)
//...

4.2 MongoDB / Mongoose

4.2.1 Aggregation

Formerly, complex document manipulation was done in Node.js server.

router.get('/feeds', async (req, res, next) => {
    const { _id: userId } = res.locals.user

    try {
        let user = await User.findById(userId)
        // User .followCount method to create .followingCount and .followerCount properties.
        user = await user.followCount()
        const reviews = await Review.find({ user: userId }).populate('book').sort('-created_at')
        const collections = await Collection.find({ user: userId }).sort('-created_at')

        return res.json({ user, reviews, collections })
    } catch (e) {
        console.error(e)
        return next(new Error('개인 ν”Όλ“œ 뢈러였기λ₯Ό μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'))
    }
})

Later, the code was refactored by using aggregation pipelines. So the process is now done in MongoDB server (Mongo Atlas).

router.get('/feeds', async (req, res, next) => {
    const { _id: userId } = res.locals.user
    const query = {
        //...
    }
    const projection = {
        //...
    }
    // Using $lookup to calculate follower / following counts.
    const followAggregation = [
        {
            '$lookup': {
                'from': 'follows',
                'let': { 'id': '$_id' },
                'pipeline': [
                    {
                        '$match': {
                            '$expr': { '$eq': ['$$id', '$sender'] },
                        },
                    },
                    { '$count': 'count' },
                ],
                'as': 'followerCount',
            },
        }, {
            '$lookup': {
                'from': 'follows',
                'let': { 'id': '$_id' },
                'pipeline': [
                    {
                        '$match': {
                            '$expr': { '$eq': ['$$id', '$receiver'] },
                        },
                    },
                    { '$count': 'count' },
                ],
                'as': 'followingCount',
            },
        }, {
            '$addFields': {
                'followerCount': {
                    '$sum': '$followerCount.count',
                },
                'followingCount': {
                    '$sum': '$followingCount.count',
                },
            },
        },
    ]
    try {
        // Simpler code with aggregation.
        const user = await User.aggregate([query, projection, ...followAggregation])
        const reviews = await Review.find({ user: userId }).populate('book').sort('-created_at')
        const collections = await Collection.find({ user: userId }).sort('-created_at')

        return res.json({ user, reviews, collections })
    } catch (e) {
        console.error(e)
        return next(new Error('개인 ν”Όλ“œ 뢈러였기λ₯Ό μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'))
    }
})

5. Dependencies 🀝

6. Contributors πŸ§‘β€πŸ€β€πŸ§‘


κΉ€μŠΉλΉˆ


ꢌ였빈