Привет Хабр!

Во время локдауна я решил поупражняться в разработке под Android. Начать решил с простой гиперказуальной игры с элементами дуэли. Взаимодействие игроков решил реализовать через отдельный сервер и websocket'ы, но пока читал про все это дело, понял, что простого туториала на русско-язычных ресурсах почему-то нет. Поэтому, решил восполнить этот пробел.

В этой статье я постараюсь описать как настроить WebSocket'ы на примере чата с сервером на SpringBoot и клиенте под Android.

Что такое WebSocket

В клиент-серверной архитектуре все взаимодействие происходит как правило через запросы от клиента к серверу в синхронном режиме. Но что делать, если запрашиваемое действие необходимо выполнить асинхронно? Или пользователь должен получить оповещение об обновлении данных, которые зависят от других пользователей или быстро теряют актуальность? Примеры таких приложений: мессенджеры, многопользовательские игры и т.д. В этом случае возникает потребность в отправке данных от сервера клиенту. Такое взаимодействие можно реализовать через polling или long-polling, что подразумевает периодический опрос сервера "не появилось ли для меня чего-то нового". Или же воспользоваться WebSocket'ами, которые позволяют реализовать двустороннюю связь в режиме реального времени.

На чем пишем?

Все будет написано на Kotlin. Сервер поднимать буду на SpringBoot. Клиент будет под Android, основные зависимости: Hilt, converter-gson, okhttp3, StompProtocolAndroid (библиотека для поддержки STOMP-клиента через websocket)

Сервер

Итак, начнем с написания сервера. Я пропущу этап инициализации SpringBoot приложения и перейдем сразу к описанию ресурсов и конфигурации сокетов. Для обмена сообщениями заведем ресурс, описывающий структуру сообщения и добавим jackson аннотаций для корректной сериализации/десериализации LocalDateTime:

@JsonInclude(JsonInclude.Include.NON_NULL)
data class ChatSocketMessage(
        val text: String,
        var author: String,
        @JsonDeserialize(using = LocalDateTimeDeserializer::class)
        @JsonSerialize(using = LocalDateTimeSerializer::class)
        var datetime: LocalDateTime,
        var receiver: String? = null
)

Создадим REST-контроллер для обработки сообщений

@RestController
@RequestMapping(LINK_CHAT)
class ChatController(
        private val simpleMessageTemplate: SimpMessagingTemplate
) {
    @MessageMapping("/sock")
    fun chatSocket(res: ChatSocketMessage) {
        sendMessageToUsers(res) //отправим сообщения другим пользователям
    }
    
    private fun sendMessageToUsers(message: ChatSocketMessage) {
        if(message.receiver != null) {
          //если сообщение отправляется в приватный чат
            simpleMessageTemplate.convertAndSendToUser(message.receiver!!, CHAT_TOPIC, message)
        } else {
          //если сообщение отправляется в общий чат
            simpleMessageTemplate.convertAndSend(CHAT_TOPIC, message)
        }
    }
}

Ну и в конце настроим WebSocket'ы на сервере

@Configuration
@EnableWebSocketMessageBroker
open class WebSocketConfig: WebSocketMessageBrokerConfigurer {

    override fun configureMessageBroker(config: MessageBrokerRegistry) {
      /*
      запускаем simple broker с перечислением топиков, на которые будем подписываться 
      При необходимости можно настраивать сторонние брокеры, 
      такой как RabbitMQ
      */  
      	config.enableSimpleBroker(TOPIC) 
        /*
        задаем префиксы для приложения и пользователя
        */
        config.setApplicationDestinationPrefixes(LINK_CHAT)
        config.setUserDestinationPrefix("/user")
    }

    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
      /*
      указываем endpoint, который будет использоваться для подключения сокетов.
      Не забываем включить SockJS. 
      При этом надо будет при подключении добавить к адресу /websocket
      */   
      registry.addEndpoint(LINK_CHAT)
                .setAllowedOrigins("*")
                .withSockJS() 
    }
}

Клиент

Помечаем наш MainActivity анотацией AndroidEntryPoint и инджектим ViewModel, которая будет отвечать за взаимодействие с сервером.

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var mainViewModel: MainViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
  			//описываем инициализацию
    }

		//...............
}

Переходим к описанию MainViewModel:

class MainViewModel @Inject constructor(
    var db: AppDb // имплементация RoomDatabase
) : ViewModel() {
    companion object{
      //указываем endpoint, на который регистрировали сокет, не забываем добавить /websocket
        const val SOCKET_URL = "ws://10.0.2.2:8080/api/v1/chat/websocket"
      // заводим название топика
      const val CHAT_TOPIC = "/topic/chat"
      //указываем endpoint метода, на который будем слать сообщения
        const val CHAT_LINK_SOCKET = "/api/v1/chat/sock"
    }

    /*
    	инициализируем Gson для сериализации/десериализации 
    	и регистрируем дополнительный TypeAdapter для LocalDateTime
    */
    private val gson: Gson = GsonBuilder().registerTypeAdapter(LocalDateTime::class.java,
        GsonLocalDateTimeAdapter()
    ).create()
    private var mStompClient: StompClient? = null
    private var compositeDisposable: CompositeDisposable = CompositeDisposable()

    private val _chatState = MutableLiveData<Message?>()
    val liveChatState: LiveData<Message?> = _chatState

    init {
      //инициализация WebSocket клиента
        mStompClient = Stomp.over(Stomp.ConnectionProvider.OKHTTP, SOCKET_URL)
            .withServerHeartbeat(30000)
        initChat() //инициализация подписок
    }
		
    private fun initChat() {
    //опишем ниже
    }
    /*
    отправляем сообщение в общий чат
    */
    fun sendMessage(text: String) {
        val chatSocketMessage = ChatSocketMessage(text = text, author = "Me")
        sendCompletable(mStompClient!!.send(CHAT_LINK_SOCKET, gson.toJson(chatSocketMessage)))
    }

    private fun sendCompletable(request: Completable) {
        compositeDisposable?.add(
            request.subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                    {
                        Log.d(TAG, "Stomp sended")
                    },
                    {
                        Log.e(TAG, "Stomp error", it)
                    }
                )
        )
    }

    override fun onCleared() {
        super.onCleared()

        mStompClient?.disconnect()
        compositeDisposable?.dispose()
    }
}

В конце настраиваем подписки на наш топик и состояние соединения

private fun initChat() {
        if (mStompClient != null) {
          //настраиваем подписку на топик
            val topicSubscribe = mStompClient!!.topic(CHAT_TOPIC)
                .subscribeOn(Schedulers.io(), false)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({ topicMessage: StompMessage ->
                    Log.d(TAG, topicMessage.payload)
                    //десериализуем сообщение
                    val message: ChatSocketMessage =
                        gson.fromJson(topicMessage.payload, ChatSocketMessage::class.java)
                  
                    addMessage(message) //пишем сообщение в БД и в LiveData
                },
                    {
                        Log.e(TAG, "Error!", it) //обработка ошибок
                    }
                )

                //подписываемся на состояние WebSocket'a
            val lifecycleSubscribe = mStompClient!!.lifecycle()
                .subscribeOn(Schedulers.io(), false)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { lifecycleEvent: LifecycleEvent ->
                    when (lifecycleEvent.type!!) {
                        LifecycleEvent.Type.OPENED -> Log.d(TAG, "Stomp connection opened")
                        LifecycleEvent.Type.ERROR -> Log.e(TAG, "Error", lifecycleEvent.exception)
                        LifecycleEvent.Type.FAILED_SERVER_HEARTBEAT,
                        LifecycleEvent.Type.CLOSED -> {
                            Log.d(TAG, "Stomp connection closed")
                        }
                    }
                }

            compositeDisposable!!.add(lifecycleSubscribe)
            compositeDisposable!!.add(topicSubscribe)

            //открываем соединение
            if (!mStompClient!!.isConnected) {
                mStompClient!!.connect()
            }


        } else {
            Log.e(TAG, "mStompClient is null!")
        }
    }

На этом все, полный код работающего чата можно найти на GitHub. Надеюсь это будет полезно тем, кто сталкивается с настройкой WebSocket'ов впервые, как и я.

Комментарии (0)