使用Kotlin和Spring Boot实现滚动浏览数据集的教程

135 阅读5分钟

当获取大量的数据条目(如用户、产品、帖子......)时,庞大的数据量往往是一个限制因素,会对后台性能(负载)以及客户端体验产生负面影响(巨大的有效载荷,漫长的加载时间)。

解决这个问题的一个好方法是以较小的块状滚动浏览数据集。当目标是先加载一些数据,然后再按需加载其余的数据时,这也很好。

在这篇文章中,我们将看看使用Kotlin和Spring Boot 2实现这一机制的情况。数据源将是一个由1000个随机生成的用户组成的列表,我们将以100个为一组进行遍历。在实践中,如果数据量大得多,你可能才会这样做,但对于这个例子来说,这是一个不错的测试规模。

有多种方法来实现这样的事情。其中一个核心问题是,滚动状态是由服务器处理,还是由客户端处理。在这个例子中,我们将在服务器上处理这个状态(记住客户端已经收到的条目数量)。这使得API非常简洁,但却牺牲了一些灵活性。

首先,让我们来了解一下我们要如何使用这个滚动的API。

使用流程

在开始的时候,我们需要做一个初始请求。

curl localhost:8080/rest/v1/user/scroll

这将触发以下响应(当然是随机的UUID)。

{"data":{"scrollParam":"94422391-b626-4926-aca9-cc7ae4e6f3a5","users":[]}}

我们从第一个请求中已经得到了一些数据,更重要的是,scrollParam ,这是一个唯一的ID,用来识别我们在后台的滚动状态。

现在,我们复制scrollParam ,以便在随后的请求中使用它,滚动浏览整个用户列表。

curl localhost:8080/rest/v1/user/scroll?scrollParam=94422391-b626-4926-aca9-cc7ae4e6f3a5

当我们完成了对整个列表的滚动,我们会得到一个空列表。

{"data":{"scrollParam":"94422391-b626-4926-aca9-cc7ae4e6f3a5","users":[]}}

一旦我们碰到一个空的列表,我们就删除滚动状态,任何进一步的请求都会导致错误。

{"error":{"message":"You must provide an active scrollParam, ...  does not exist"}}

客户端可以把空列表或错误作为停止请求新数据的信号。

这就是我们要建立的基本理念--所以让我们开始编码吧!

代码示例

为了简洁起见,这里省略了Spring Boot的模板,但完整的代码在GitHub上有。

好的,首先是用户的数据模型。

data class User (
    val id: Int,
    val firstName: String,
    val lastName: String,
    val email: String
)

说完这个,让我们看看控制器的定义。它在GET /rest/v1/user/scroll ,等待请求。

@RestController
@RequestMapping("rest/v1/user")
class ScrollingController(
        private val userService: UserService
) {
    companion object : KLogging()

    @GetMapping("/scroll")
    fun listUsers(
            @RequestParam(name = "scrollParam", required = false) scrollParam: String?
    ): ResponseEntity<ResultData<UserScrollingDTO>> {
        return try {
            val result = userService.scrollUsers(scrollParam)
            createSuccess(UserScrollingDTO(
                            scrollParam = result.scrollParam,
                            users = result.users
                        ))
        } catch (e: UserScrollParamNotFoundInCacheException) {
            return createError("You must provide an active scrollParam, the scrollParam `$scrollParam` does not exist")
        }
    }
}

有一个可选的scrollParam 参数。记得在讨论上面的使用流程时,第一个请求不会有scrollParam,但后面的每个请求都需要它。因此,如果一个客户端在没有scrollParam的情况下请求端点,我们就会在后端创建一个新的滚动状态。

scrollUsers 端点调用userService 中的方法,这是该机制的核心部分。对于服务层,我们也需要两个数据类。

data class UserScrollingResult(
    var scrollParam: String,
    var users: List<User>
)

data class UserScrollRequest(
    var scrollParam: String,
    var cursor: Int
)

第一个是简单的结果包装器,以保持控制器和服务层之间的干净分离。UserScrollRequest 表示滚动的状态。这就是它的全部内容,我们保存一个UUID和客户端最后收到的ID。

对于更高级的用例,这个请求对象还可以包括一些过滤列表的请求参数。有了这个,客户端只需要在初始请求时提供这些过滤器,而对于其余的请求,scrollParam 就足够了。

重要的是,要使用客户端收到的最后一个数据点的ID ,而不是一味地往上数,因为数据集的间歇性变化可能导致条目的顺序错误以及重复。

在我们进入服务方法之前,需要进行一些设置。为了有效地保持滚动状态(如果有必要的话,在我们的应用程序的几个实例中),我们将把它放到一个共享的缓冲区。

@Service
class UserService {
    private val listOfUsers: List<User> = initializeUserList()
    private val cacheManager: CacheManager = ConcurrentMapCacheManager(CACHE_KEY_SCROLLING)

    private fun initializeUserList(): List<User> {
        val result = ArrayList<User>()
        for (i in 0..1000) {
            result.add(User(
                    i,
                    getRandomString(),
                    getRandomString(),
                    "${getRandomString()}@${getRandomString()}.${getRandomString()}"
            ))
        }
        return result
    }

    private fun getRandomString(): String = RandomStringUtils.randomAlphanumeric(3, 15)
    ...
}

在这个例子中,它是一个简单的内存地图,但这很容易成为一个外部Redis实例。

在缓存初始化之后,我们还要初始化我们随机生成的用户列表。这里没有什么花哨的事情发生,只是把一些随机的字符串扔在一个带有ID的User 对象中。

所有的设置都结束了,让我们来看看有趣的东西。scrollUsers 服务方法封装了整个保存、更新和驱逐滚动状态的机制,以及创建UserScrollingResult

fun scrollUsers(scrollParam: String?): UserScrollingResult {
    val scrollRequest: UserScrollRequest
    val activeScrollParam = scrollParam ?: UUID.randomUUID().toString()
    scrollRequest = if (scrollParam == null) {
        putUserScrollRequestInCache(activeScrollParam)
    } else {
        getUserScrollRequestFromCache(scrollParam)
    }
    val users = fetchUsers(scrollRequest.cursor)
    if (users.isEmpty()) {
        evictUserScrollRequestFromCache(activeScrollParam)
    } else {
        updateUserScrollRequestCursorInCache(activeScrollParam, users.last().id)
    }
    return UserScrollingResult(activeScrollParam, users)
}

好吧,在上面的片段中发生了很多事情。首先,如果我们得到了一个scrollParam ,我们就使用它,否则就创建一个新的UUID

如果没有提供scrollParam ,我们就创建一个新的滚动状态并把它放在缓存中,否则我们就从缓存中获取给定的scrollParam 的现有滚动状态。

private fun putUserScrollRequestInCache(
        scrollParam: String
): UserScrollRequest {
    val scrollRequest = UserScrollRequest(
            scrollParam = scrollParam,
            cursor = 0
    )
    cacheManager.getCache(CACHE_KEY_SCROLLING)!!.put(scrollParam, scrollRequest)
    return scrollRequest
}

private fun getUserScrollRequestFromCache(scrollParam: String): UserScrollRequest {
    val value = cacheManager.getCache(CACHE_KEY_SCROLLING)!!.get(scrollParam)
    if (value != null && value.get() is UserScrollRequest) {
        return value.get() as UserScrollRequest
    }
    throw UserScrollParamNotFoundInCacheException(scrollParam)
}

如果缓存中没有给定UUID 的滚动状态,我们就抛出一个异常。

然后,利用我们的滚动状态,我们获取当前cursor 的用户。现在,如果返回的用户列表是空的,我们就处于列表的末端,我们从缓存中驱逐滚动状态。

如果不是,我们就用新的游标来更新滚动状态。

private fun updateUserScrollRequestCursorInCache(scrollParam: String, cursor: Int): UserScrollRequest? {
    val value = cacheManager.getCache(CACHE_KEY_SCROLLING)!!.get(scrollParam)
    if (value == null || value.get() !is UserScrollRequest) {
        return null
    }
    val scrollRequest = value.get() as UserScrollRequest
    scrollRequest.cursor = cursor
    cacheManager.getCache(CACHE_KEY_SCROLLING)!!.put(scrollParam, scrollRequest)
    return scrollRequest
}

private fun evictUserScrollRequestFromCache(scrollParam: String) =
        cacheManager.getCache(CACHE_KEY_SCROLLING)!!.evict(scrollParam)

UserScrollResult 在所有这些之后,唯一剩下的就是返回一个scrollParam 和要返回的用户列表。

实际上,在这个设计好的案例中,获取用户只是在我们随机生成的用户列表中进行一点光标计数而已。

private fun fetchUsers(cursor: Int): List<User> {
    val result = ArrayList<User>()
    if (cursor > listOfUsers.size) return result
    for (user in listOfUsers) {
        if (user.id <= cursor) continue
        if (user.id >= cursor + SCROLL_SIZE) return result
        result.add(user)
    }
    return result
}

在现实世界的实现中,fetchUsers 函数可能会从数据库或其他网络服务中获取数据。

好了,就这样吧!

这个例子的全部代码可以在这里找到。

结语

我真的很喜欢Kotlin,尤其是之前做过一段时间的Java。由于Spring Boot的强大功能和Kotlin的便利性和简洁性,以一种相对接近真实的方式实现这一机制并没有花费我很多时间。

在过去的6个月里,我在制作微服务时经常使用Kotlin,我仍然对这个选择非常满意。)

资源