add examples for services
This commit is contained in:
parent
85b1931673
commit
598e1c931c
@ -1,20 +1,47 @@
|
|||||||
plugins{
|
plugins{
|
||||||
kotlin("jvm") version "2.0.20"
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.ktor)
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "center.sciprog.demo"
|
||||||
|
version = "0.0.1"
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("serverMainKt")
|
||||||
|
|
||||||
|
val isDevelopment: Boolean = project.ext.has("development")
|
||||||
|
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
val ktorVersion = "3.0.1"
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
implementation(libs.kotlinx.html)
|
||||||
|
implementation(libs.kotlin.css)
|
||||||
|
implementation(libs.logback.classic)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
implementation("io.ktor:ktor-client-core:$ktorVersion")
|
|
||||||
implementation("io.ktor:ktor-client-cio:$ktorVersion")
|
|
||||||
implementation("io.ktor:ktor-client-core-jvm:3.0.1")
|
|
||||||
implementation("io.ktor:ktor-client-apache:3.0.1")
|
|
||||||
|
|
||||||
implementation("ch.qos.logback:logback-classic:1.5.12")
|
implementation(libs.ktor.server.core)
|
||||||
|
implementation(libs.ktor.server.websockets)
|
||||||
|
implementation(libs.ktor.server.html.builder)
|
||||||
|
implementation(libs.ktor.server.call.logging)
|
||||||
|
implementation(libs.ktor.server.cors)
|
||||||
|
implementation(libs.ktor.server.host.common)
|
||||||
|
implementation(libs.ktor.server.cio)
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|
||||||
|
// implementation("io.ktor:ktor-client-core:$ktorVersion")
|
||||||
|
// implementation("io.ktor:ktor-client-cio:$ktorVersion")
|
||||||
|
// implementation("io.ktor:ktor-client-core-jvm:3.0.1")
|
||||||
|
// implementation("io.ktor:ktor-client-apache:3.0.1")
|
||||||
|
|
||||||
|
testImplementation(libs.ktor.server.test.host)
|
||||||
|
testImplementation(libs.kotlin.test.junit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
30
gradle/libs.versions.toml
Normal file
30
gradle/libs.versions.toml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
[versions]
|
||||||
|
kotlin-version = "2.0.21"
|
||||||
|
kotlinx-html-version = "0.11.0"
|
||||||
|
ktor-version = "3.0.1"
|
||||||
|
logback-version = "1.4.14"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor-version" }
|
||||||
|
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets-jvm", version.ref = "ktor-version" }
|
||||||
|
ktor-server-html-builder = { module = "io.ktor:ktor-server-html-builder-jvm", version.ref = "ktor-version" }
|
||||||
|
kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinx-html-version" }
|
||||||
|
kotlin-css = { module = "org.jetbrains:kotlin-css-jvm", version = "1.0.0-pre.129-kotlin-1.4.20" }
|
||||||
|
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging-jvm", version.ref = "ktor-version" }
|
||||||
|
ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor-version" }
|
||||||
|
ktor-server-host-common = { module = "io.ktor:ktor-server-host-common-jvm", version.ref = "ktor-version" }
|
||||||
|
ktor-server-cio = { module = "io.ktor:ktor-server-cio-jvm", version.ref = "ktor-version" }
|
||||||
|
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-version" }
|
||||||
|
ktor-server-test-host = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor-version" }
|
||||||
|
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin-version" }
|
||||||
|
kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.6.1"
|
||||||
|
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-version" }
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-version" }
|
||||||
|
ktor-client-apache = { module = "io.ktor:ktor-client-apache", version.ref = "ktor-version" }
|
||||||
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor-version" }
|
||||||
|
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-version" }
|
||||||
|
ktor = { id = "io.ktor.plugin", version.ref = "ktor-version" }
|
54
src/main/kotlin/aggregatorClient.kt
Normal file
54
src/main/kotlin/aggregatorClient.kt
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
|
import io.ktor.client.plugins.websocket.webSocket
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import io.ktor.websocket.readText
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import io.ktor.client.engine.cio.CIO as ClientCIO
|
||||||
|
|
||||||
|
suspend fun CoroutineScope.aggregateFromService(url: String): List<Instant> {
|
||||||
|
val client = HttpClient(ClientCIO) {
|
||||||
|
install(WebSockets)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = CompletableDeferred<List<Instant>>()
|
||||||
|
|
||||||
|
launch {
|
||||||
|
client.webSocket(url) {
|
||||||
|
val res = incoming.receiveAsFlow()
|
||||||
|
.filterIsInstance<Frame.Text>()
|
||||||
|
.take(3)
|
||||||
|
.map { Instant.parse(it.readText()) }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
result.complete(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
//suspend fun aggregateFromServiceAsync(url: String): Deferred<List<Instant>> {
|
||||||
|
// val client = HttpClient(ClientCIO) {
|
||||||
|
// install(WebSockets)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// val result = CompletableDeferred<List<Instant>>()
|
||||||
|
//
|
||||||
|
// client.webSocket(url) {
|
||||||
|
// val res = incoming.consumeAsFlow()
|
||||||
|
// .filterIsInstance<Frame.Text>()
|
||||||
|
// .take(3)
|
||||||
|
// .map { Instant.parse(it.readText()) }
|
||||||
|
// .toList()
|
||||||
|
//
|
||||||
|
// result.complete(res)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return result
|
||||||
|
//}
|
34
src/main/kotlin/examples/exceptionHandling.kt
Normal file
34
src/main/kotlin/examples/exceptionHandling.kt
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package examples
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
|
||||||
|
fun main(): Unit = runBlocking{
|
||||||
|
val masterJob = launch(
|
||||||
|
CoroutineExceptionHandler { coroutineContext, throwable ->
|
||||||
|
println(throwable)
|
||||||
|
}
|
||||||
|
){
|
||||||
|
supervisorScope {
|
||||||
|
val subJob = launch {
|
||||||
|
// println(coroutineContext[Job])
|
||||||
|
delay(100)
|
||||||
|
println("Interrupting")
|
||||||
|
error("BOOM!")
|
||||||
|
}
|
||||||
|
|
||||||
|
val subDeferred = async {
|
||||||
|
delay(200)
|
||||||
|
println("Task completed")
|
||||||
|
"Completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
// subJob.await()
|
||||||
|
println(subDeferred.await())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// delay(50)
|
||||||
|
// masterJob.cancel()
|
||||||
|
masterJob.join()
|
||||||
|
println("Master job joined")
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
package examples
|
||||||
|
|
||||||
import java.net.*
|
import java.net.*
|
||||||
import java.net.http.*
|
import java.net.http.*
|
||||||
import java.net.http.HttpResponse.*
|
import java.net.http.HttpResponse.*
|
||||||
@ -13,11 +15,11 @@ fun main() {
|
|||||||
|
|
||||||
client.sendAsync(request, BodyHandlers.ofString())
|
client.sendAsync(request, BodyHandlers.ofString())
|
||||||
.thenApply{ it.body() }
|
.thenApply{ it.body() }
|
||||||
.thenApply{
|
.thenApply{ body ->
|
||||||
val resources = regex.findAll(it).map{it.groupValues[1]}
|
val resources = regex.findAll(body).map{it.groupValues[1]}
|
||||||
|
|
||||||
resources.map { resourceName->
|
resources.toList().map { resourceName->
|
||||||
println("Resource processing for $resourceName started")
|
println("Resource processing for $resourceName")
|
||||||
val resourceRequest : HttpRequest = HttpRequest.newBuilder()
|
val resourceRequest : HttpRequest = HttpRequest.newBuilder()
|
||||||
.uri(URI.create("https://sciprog.center$resourceName"))
|
.uri(URI.create("https://sciprog.center$resourceName"))
|
||||||
.GET().build()
|
.GET().build()
|
||||||
@ -26,6 +28,8 @@ fun main() {
|
|||||||
//do something with the body
|
//do something with the body
|
||||||
println("The resource with name $resourceName has ${bodyBytes.size} bytes")
|
println("The resource with name $resourceName has ${bodyBytes.size} bytes")
|
||||||
}
|
}
|
||||||
|
}.forEach {
|
||||||
|
it.join()
|
||||||
}
|
}
|
||||||
resources
|
resources
|
||||||
}
|
}
|
32
src/main/kotlin/examples/jobExample.kt
Normal file
32
src/main/kotlin/examples/jobExample.kt
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package examples
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
|
||||||
|
val job1 = launch {
|
||||||
|
delay(200)
|
||||||
|
println("Job1 completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val deferred1 = async {
|
||||||
|
delay(200)
|
||||||
|
return@async "Complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
val job2 = launch {
|
||||||
|
delay(100)
|
||||||
|
job1.cancel()
|
||||||
|
println("Job1 canceled")
|
||||||
|
deferred1.cancel()
|
||||||
|
println("Deferred1 canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
job1.join()
|
||||||
|
println("Job1 joined")
|
||||||
|
println(deferred1.await())
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
|
package examples
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsBytes
|
import io.ktor.client.statement.bodyAsBytes
|
||||||
@ -24,3 +25,4 @@ suspend fun main() = coroutineScope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
45
src/main/kotlin/microservice/asyncResponse.kt
Normal file
45
src/main/kotlin/microservice/asyncResponse.kt
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package microservice
|
||||||
|
|
||||||
|
typealias RequestArgs = Map<String, String>
|
||||||
|
typealias ComputationData = Map<String, String>
|
||||||
|
typealias Response = Map<String, String>
|
||||||
|
|
||||||
|
data class ServerRequest(
|
||||||
|
val user: String,
|
||||||
|
val token: String,
|
||||||
|
val action: String,
|
||||||
|
val arguments: RequestArgs
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ValidationService {
|
||||||
|
suspend fun isValid(user: String, token: String, action: String): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataBaseService {
|
||||||
|
suspend fun provide(arguments: RequestArgs): ComputationData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComputationService {
|
||||||
|
suspend fun compute(action: String, arguments: ComputationData): Response
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComputationContext(
|
||||||
|
val validationService: ValidationService,
|
||||||
|
val dataBaseServiceA: DataBaseService,
|
||||||
|
val dataBaseServiceB: DataBaseService,
|
||||||
|
val dataBaseServiceC: DataBaseService,
|
||||||
|
val computationService: ComputationService
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun ComputationContext.respond(request: ServerRequest): Response {
|
||||||
|
val isValid = validationService.isValid(request.user, request.token, request.action)
|
||||||
|
if (isValid) {
|
||||||
|
val dataA = dataBaseServiceA.provide(request.arguments)
|
||||||
|
val dataB = dataBaseServiceB.provide(request.arguments)
|
||||||
|
val dataC = dataBaseServiceC.provide(request.arguments)
|
||||||
|
val result = computationService.compute(request.action, dataA + dataB + dataC)
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
error("Illegal access")
|
||||||
|
}
|
||||||
|
}
|
69
src/main/kotlin/microservice/pricingService.kt
Normal file
69
src/main/kotlin/microservice/pricingService.kt
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package microservice
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
interface PriceReader {
|
||||||
|
suspend fun readPrice(): BigDecimal
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceWriter {
|
||||||
|
suspend fun writePrice(price: BigDecimal)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceDB {
|
||||||
|
suspend fun storePrice(time: Instant, price: BigDecimal)
|
||||||
|
|
||||||
|
suspend fun restorePrice(timeRange: ClosedRange<Instant>): List<Pair<Instant, BigDecimal>>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private suspend fun computePrice(history: List<Pair<Instant, BigDecimal>>): BigDecimal = TODO()
|
||||||
|
|
||||||
|
private val readDelay = 5.seconds
|
||||||
|
private val writeDelay = 10.seconds
|
||||||
|
|
||||||
|
fun CoroutineScope.launchPriceUpdate(
|
||||||
|
reader: PriceReader,
|
||||||
|
writer: PriceWriter,
|
||||||
|
db: PriceDB,
|
||||||
|
duration: Duration
|
||||||
|
): Job = launch {
|
||||||
|
val cache = ArrayDeque<Pair<Instant, BigDecimal>>()
|
||||||
|
|
||||||
|
|
||||||
|
//read job
|
||||||
|
launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(readDelay)
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val price = reader.readPrice()
|
||||||
|
db.storePrice(now, price)
|
||||||
|
cache.addFirst(now to price)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//write job
|
||||||
|
launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(writeDelay)
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val computedPrice = computePrice(cache.filter { it.first > (now - duration) })
|
||||||
|
writer.writePrice(computedPrice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//cache cleanup job
|
||||||
|
launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(1.minutes)
|
||||||
|
val now = Clock.System.now()
|
||||||
|
cache.removeIf { now - it.first > duration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
78
src/main/kotlin/serverMain.kt
Normal file
78
src/main/kotlin/serverMain.kt
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.cio.CIO
|
||||||
|
import io.ktor.server.engine.embeddedServer
|
||||||
|
import io.ktor.server.request.receiveText
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.response.respondText
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.websocket.WebSockets
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.newFixedThreadPoolContext
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
fun Route.subroute(){
|
||||||
|
var content = ""
|
||||||
|
|
||||||
|
get("get") {
|
||||||
|
call.respondText("Content: ${content}")
|
||||||
|
}
|
||||||
|
|
||||||
|
post("set"){
|
||||||
|
content = call.receiveText()
|
||||||
|
call.respond(HttpStatusCode.OK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun executeQuery(queryName: String, ioContext: CoroutineContext = Dispatchers.IO): String{
|
||||||
|
withContext(ioContext){
|
||||||
|
//some blocking logic
|
||||||
|
}
|
||||||
|
return queryName
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
embeddedServer(CIO, port = 8080, host = "localhost") {
|
||||||
|
install(WebSockets)
|
||||||
|
|
||||||
|
val ioContext = newFixedThreadPoolContext(12, "DB") //Dispatchers.IO
|
||||||
|
|
||||||
|
routing {
|
||||||
|
get("/") {
|
||||||
|
val callerName = call.queryParameters["name"] ?: "World"
|
||||||
|
call.respondText("Hello $callerName!")
|
||||||
|
}
|
||||||
|
get("/query/{queryName}"){
|
||||||
|
val queryName = call.parameters["queryName"] ?: "query"
|
||||||
|
val queryResult = executeQuery(queryName, ioContext)
|
||||||
|
call.respondText("$queryName successful: $queryResult")
|
||||||
|
}
|
||||||
|
route("subroute"){
|
||||||
|
subroute()
|
||||||
|
}
|
||||||
|
route("subroute1"){
|
||||||
|
subroute()
|
||||||
|
}
|
||||||
|
|
||||||
|
route("producer"){
|
||||||
|
streamingModule()
|
||||||
|
}
|
||||||
|
|
||||||
|
get("aggregated"){
|
||||||
|
val result = aggregateFromService("ws://localhost:8080/producer")
|
||||||
|
call.respondText(result.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}.start(wait = true)
|
||||||
|
}
|
39
src/main/kotlin/streamingModule.kt
Normal file
39
src/main/kotlin/streamingModule.kt
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.application
|
||||||
|
import io.ktor.server.websocket.webSocket
|
||||||
|
import io.ktor.websocket.Frame
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.time.Duration.Companion.microseconds
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
||||||
|
fun Route.streamingModule() {
|
||||||
|
val channel = Channel<Instant>()
|
||||||
|
|
||||||
|
application.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(0.1.seconds)
|
||||||
|
channel.send(Instant.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket {
|
||||||
|
repeat(3){
|
||||||
|
delay(100.milliseconds)
|
||||||
|
outgoing.send(Frame.Text(Instant.now().toString()))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// launch {
|
||||||
|
// while (isActive) {
|
||||||
|
// outgoing.send(Frame.Text(channel.receive().toString()))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user