当获取大量的数据条目(如用户、产品、帖子......)时,庞大的数据量往往是一个限制因素,会对后台性能(负载)以及客户端体验产生负面影响(巨大的有效载荷,漫长的加载时间)。
解决这个问题的一个好方法是以较小的块状滚动浏览数据集。当目标是先加载一些数据,然后再按需加载其余的数据时,这也很好。
在这篇文章中,我们将看看使用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,我仍然对这个选择非常满意。)