Websocket實現後端主動向Android推送任務

前言

之前不少推送都是經過前端經過設定必定的時間間隔不斷的向服務器獲取推送消息,不過這樣的缺點是浪費了不少服務器資源,並且也有可能被人濫用,致使服務器異常。因而乎出現了websocket協議。websocket協議的好處是能夠實現持久性鏈接,能更好的節省服務器資源和帶寬,而且可以更實時地進行通信。本篇文章主要講的是利用springboot+websocket實現後端向前端推送消息的功能。css

資源

  • linux服務器一臺(用於項目的部署)
  • intellij idea (用於後端代碼編寫軟件)
  • android studio (android代碼編寫軟件)

目的及效果

實現服務器後端向不一樣渠道,不一樣用戶羣體發送不一樣類型的通知(好比狀態了通知,啓動彈窗等)。
羣推:全部在線設備都將收到推送
個推:只有輸入的設備id號才能夠收到推送
渠推:針對某一個市場渠道進行推送
條件推送:多個條件組合起來的推送,好比用戶年齡區間,市場渠道等針對性比較強的推送
html

後端實現

使用indellij idea建立項目

步驟一:

步驟二:

由於咱們要用到maven,maven在國內很難鏈接上或者根本有時鏈接不上,這是須要修改一下setting.xml中的內容,若是沒有就建立一下,步驟以下。存在setting.xml時顯示的是open "setting.xml",不存在則顯示 create "setting.xml"

setting.xml中的內容以下:前端

<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
    <mirrors>
        <!-- mirror
         | Specifies a repository mirror site to use instead of a given repository. The repository that
         | this mirror serves has an ID that matches the mirrorOf element of this mirror. IDs are used
         | for inheritance and direct lookup purposes, and must be unique across the set of mirrors.
         |
        <mirror>
          <id>mirrorId</id>
          <mirrorOf>repositoryId</mirrorOf>
          <name>Human Readable Name for this Mirror.</name>
          <url>http://my.repository.com/repo/path</url>
        </mirror>
         -->

        <mirror>
            <id>alimaven</id>
            <name>aliyun maven</name>
            <url>http://maven.aliyun.com/nexus/content/groups/public/</url>
            <mirrorOf>central</mirrorOf>
        </mirror>

        <mirror>
            <id>uk</id>
            <mirrorOf>central</mirrorOf>
            <name>Human Readable Name for this Mirror.</name>
            <url>http://uk.maven.org/maven2/</url>
        </mirror>

        <mirror>
            <id>CN</id>
            <name>OSChina Central</name>
            <url>http://maven.oschina.net/content/groups/public/</url>
            <mirrorOf>central</mirrorOf>
        </mirror>

        <mirror>
            <id>nexus</id>
            <name>internal nexus repository</name>

            <url>http://repo.maven.apache.org/maven2</url>
            <mirrorOf>central</mirrorOf>
        </mirror>

    </mirrors>

</settings>
複製代碼

改配置後就很容易鏈接上了。下面的圖片是項目的目錄結構圖java

bean:存放基本的bean對象
config:配置類,該項目中是存放websocket的配置類
controller:控制器,裏面能夠經過不一樣的路由地址放回不一樣的json數據或界面
utils:存放工具類
static:存放靜態文件,好比css,js之類的
templates:存放html模板,用戶前端界面展現linux

步驟三:

配置一下依賴,下面是個人poem.xml文件內容android

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.push</groupId>
    <artifactId>push</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>push</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <!-- 這個須要爲 true 熱部署纔有效 -->
            <optional>true</optional>
        </dependency>

        <!-- servlet依賴. -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <scope>provided</scope>
        </dependency>
        
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jstl</artifactId>
        </dependency>

        <!-- 咱們須要toncat,這是打開對tomcat的支持.-->
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <scope>provided</scope>
        </dependency>

        <!-- 引入websocket-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

        <!-- 魔板,前端頁面時須要此依賴,不過不用也可使用前端界面,不過麻煩些,引入這個庫後,前端界面放到resource裏的templates文件夾下,靜態內容放到resource裏的static文件夾下,好比要引入的css或js這些-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!-- gson比較好用,json處理時方便些,也引入了庫-->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 使用mvn命令打包成jar包時須要用到-->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>


        </plugins>
    </build>

</project>
複製代碼

裏面比較重要的依賴都標有註釋了web

步驟四

編寫controller和websocket的一個配置類,controller主要用於對各類鏈接進行攔截並作出相應的處理,websockt配置類裏面項目中主要是對websocket地址進行攔截。websocket代碼以下:spring

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 在這裏註冊一下user,用於攔截普通用戶的鏈接
        registry.addHandler(new UserHandler(),"/user")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .setAllowedOrigins("*");

        //這裏註冊一下admin,用於攔截管理員的鏈接,其實管理員在此項目中中須要獲取在線的普通用戶數量而已
        registry.addHandler(new AdminHandler(),"/admin")
                .addInterceptors(new HttpSessionHandshakeInterceptor())
                .setAllowedOrigins("*");
    }
}

複製代碼

controller的代碼以下:apache

@Controller
public class PushMainEnterController {


    /**
     * 羣發消息
     * @param messageBean 記錄後臺管理頁面傳過來的內容
     * @return
     */
    @ResponseBody()
    @RequestMapping(value = "/sendAll",method = RequestMethod.POST  , produces = "application/json;charset=UTF-8")
    public String sendAll(@RequestBody MessageBean messageBean){
            Gson gson = new Gson();
            if (messageBean != null){
                String backInfo = gson.toJson(messageBean);
                //發送消息前端設備
                WebSocketUtils.sendMessageToUser(backInfo);
            }else {
                //告知管理員消息發送失敗
                WebSocketUtils.sendMessageToAdmin("發送失敗");
            }
            return "{}";
    }
    
    //其餘的個推,條件推送,渠道推送和羣發消息的代碼基本如出一轍,只是WebSocketUtils中調用的方法不一樣而已

    /**
     * 鏈接是默認跳轉到starter.html這個網頁下,這個網頁就是管理後臺的主界面
     * @param mv
     * @return
     */
    @ResponseBody
    @RequestMapping(value="/")
    public ModelAndView index(ModelAndView mv){
        mv.setViewName("starter");
        return mv;
    }

}
複製代碼

WebSocketUtils輔助類的編寫,主要方便發送消息,添加用戶等操做。這裏本來我是想經過使用hashmap保存的,這樣方便直接經過key獲取內容,不過考慮到hashmap是線程不安全的,在多線程讀寫時可能存在問題,因此改用CopyOnWriteArraySet了。編程

public class WebSocketUtils {
    /**
     * 用來存放普通用戶Session
     */
    private static CopyOnWriteArraySet<WebSocketSession> usersSessionSet = new CopyOnWriteArraySet<>();
    /**
     * 用來存放管理員Session
     */
    private static CopyOnWriteArraySet<WebSocketSession> adminSessionSet = new CopyOnWriteArraySet<>();

    /**
     * 添加管理員
     * @param socketSession
     */
    public static synchronized void addAdmin(WebSocketSession socketSession){
        adminSessionSet.add(socketSession);
    }

    /**
     * 添加普通用戶
     * @param socketSession
     */
    public static synchronized void addUser(WebSocketSession socketSession){
        usersSessionSet.add(socketSession);
    }

    /**
     * 刪除管理員
     * @param webSocketSession
     */
    public static synchronized void removeAdmin(WebSocketSession webSocketSession){
        adminSessionSet.remove(webSocketSession);
    }

    /**
     * 刪除普通用戶
     * @param webSocketSession
     */
    public static synchronized void removeUser(WebSocketSession webSocketSession){
        usersSessionSet.remove(webSocketSession);
    }

    /**
     * 獲取管理員在線人數
     * @return
     */
    public static synchronized int getAdminOnlineCount(){
        return adminSessionSet.size();
    }

    /**
     * 獲取普通用戶在線人數
     * @return
     */
    public static synchronized int getUserOnlineCount(){
        return usersSessionSet.size();
    }

    /**
     * 發送消息給管理員
     */
    public static void sendMessageToAdmin(){
        adminSessionSet.forEach(webSocketSession -> {
            try {
                webSocketSession.sendMessage(new TextMessage("在線人數爲:"+getUserOnlineCount()));
            } catch (IOException e) {
                e.printStackTrace();
                System.out.println("發送消息給管理員失敗:"+e.getLocalizedMessage());
            }
        });
    }

    /**
     * 發送消息給管理員
     */
    public static void sendMessageToAdmin(String msg){
        adminSessionSet.forEach(webSocketSession -> {
            try {
                webSocketSession.sendMessage(new TextMessage(msg));
            } catch (IOException e) {
                e.printStackTrace();
                System.out.println("發送消息給管理員失敗:"+e.getLocalizedMessage());
            }
        });
    }


    /**
     * 發送消息給全部用戶
     * @param msg
     */

    public static void sendMessageToUser(String msg){
        usersSessionSet.forEach(usersSessionSet->{
            try {
                usersSessionSet.sendMessage(new TextMessage(msg));
            } catch (IOException e) {
                e.printStackTrace();
                System.out.println("消息發送失敗");
            }
        });
    }

    /**
     * 發送給某個用戶
     */

    public static void sendMessageToUserForSingle(String msg){
        Gson gson = new Gson();
        MessageBean messageBean = gson.fromJson(msg,MessageBean.class);
        usersSessionSet.forEach(webSocketSession -> {
            String[] path = webSocketSession.getUri().getQuery().split("&");
            HashMap<String,String> map = new HashMap<>();
            for (int i=0;i<path.length;i++){
                String[] para= path[i].split("=");
                map.put(para[0],para[1]);
            }
            if (map.get("id").equals(messageBean.getTargetId())){
                try {
                    webSocketSession.sendMessage(new TextMessage(msg));
                } catch (IOException e) {
                    e.printStackTrace();
                    System.out.println("發送消息給用戶失敗:"+e.getLocalizedMessage());
                }
            }

        });
    }

    /**
     * 發送給某個渠道
     */

    public static void sendMessageToUserForChannel(String msg){
        Gson gson = new Gson();
        MessageBean messageBean = gson.fromJson(msg,MessageBean.class);
        usersSessionSet.forEach(webSocketSession -> {
            String[] path = webSocketSession.getUri().getQuery().split("&");
            HashMap<String,String> map = new HashMap<>();
            for (int i=0;i<path.length;i++){
                String[] para= path[i].split("=");
                map.put(para[0],para[1]);
            }
            if (Integer.valueOf(map.get("channel")) == messageBean.getChannel()){
                try {
                    webSocketSession.sendMessage(new TextMessage(msg));
                } catch (IOException e) {
                    e.printStackTrace();
                    System.out.println("發送消息給用戶失敗:"+e.getLocalizedMessage());
                }
            }

        });
    }


    /**
     * 經過條件發送信息
     */

    public static void sendMessageToUserForCondition(String msg){
        Gson gson = new Gson();
        MessageBean messageBean = gson.fromJson(msg,MessageBean.class);
        usersSessionSet.forEach(webSocketSession -> {
            //讀取前端鏈接時的地址,從地址中獲取條件參數,並保存到hashmap中去
            String[] path = webSocketSession.getUri().getQuery().split("&");
            HashMap<String,String> map = new HashMap<>();
            for (int i=0;i<path.length;i++){
                String[] para= path[i].split("=");
                map.put(para[0],para[1]);
            }
            //判斷渠道是否符合條件參數,符合則發送消息
            if (Integer.valueOf(map.get("channel")) == messageBean.getChannel() && Integer.valueOf(map.get("age"))>messageBean.getMinYear() && Integer.valueOf(map.get("age"))<messageBean.getMaxYear()){
                try {
                    webSocketSession.sendMessage(new TextMessage(msg));
                } catch (IOException e) {
                    e.printStackTrace();
                    System.out.println("發送消息給用戶失敗:"+e.getLocalizedMessage());
                }
            }

        });
    }
}

複製代碼

構造一個messagebean,這個類主要用於接受前端管理後臺傳過來的發送參數,好比推送標題,推送內容,推送渠道等。

//setting和getting就不列出來了
public class MessageBean {
    private String title;//推送標題
    private String content;//推送內容
    private String imageUrl;//推送圖片地址
    private int type;//推送類型
    private String targetId;//推送目標id
    private int minYear;//推送限定的最小接收年齡
    private int maxYear;//推送限定的最大接收年齡
    private int channel = -1;//推送渠道
}
複製代碼

定義兩個handle,用來處理用戶鏈接和管理員鏈接時產生的動做。當用戶鏈接成功或斷開鏈接時,會向管理員推送消息,以便管理員獲取在線用戶,這裏只貼出用戶的handle

public class UserHandler implements WebSocketHandler {

    /**
    *鏈接成功時調用此方法
    */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String para = session.getUri().getQuery();

        WebSocketUtils.addUser(session);
        //向管理員彙報當前在線人數
        WebSocketUtils.sendMessageToAdmin();
    }

    /**
    *收到消息時調用此方法
    */
    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
        
    }

    /**
    *鏈接異常時調用此方法
    */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("用戶鏈接失敗");
    }

    /**
    *關閉鏈接時調用此方法
    */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        System.out.println("用戶退出鏈接");
        WebSocketUtils.removeUser(session);
        //向管理員彙報當前人數
        WebSocketUtils.sendMessageToAdmin();

    }

    @Override
    public boolean supportsPartialMessages() {
        return false;
    }   
}
複製代碼

到這裏後端的代碼基本完成了,adminhandle的代碼與userhandle雷同,固然若是不須要動態統計人數到管理界面展現,adminhanle也不必編寫了。

打包代碼成jar格式並部署到服務器

打包

剛開始時我直接用intellij idea打包成jar包,結果是打出了jar包,可是這個包實際上無法運行,運行時會提示「缺乏清單文件」這類提示,大體就是缺乏了META-INF這個文件。因而上網找了一下,發現使用mvn命令能夠打包,因而又用mvn cleanmvn install命令打了一次包,結果沒打成功,出現一個錯誤: Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.22.2:test (default-test) on project push: There are test failures.。感受無解而後又找了一下其餘命令,最後看到網上有一位大神說這種狀況可使用另一個命令打包。mvn clean package -Dmaven.test.skip=true,後來的確也試了一下這個命令,的確是可行的

上傳包

這裏咱們使用scp命令上傳jar包到服務器(個人編程環境是linux系統的),使用的命令是:scp 文件名 用戶名@服務器ip地址:服務器目標文件夾,好比scp pushAdmin.jar root@xxx.xxx.xxx.xxx:/home

啓動服務器

使用ssh命令鏈接服務器。好比ssh root@xxx.xxx.xxx.xxx,鏈接上後找到剛纔上傳的jar包,讓後使用nohup java -jar 包名.jar &啓動並掛起後臺。到這裏就部署完了,這時外網就能夠正常訪問後臺管理了。

Android代碼的實現

android鏈接websocket咱們就用okhttp3去實現,既然要使用okhttp3,就必須引入依賴,依賴以下

implementation 'com.squareup.okhttp3:okhttp:3.8.1'
    implementation 'com.squareup.okhttp3:mockwebserver:3.8.1'
複製代碼

下面的代碼只貼出鏈接部分的代碼,其餘非核心的內容就不貼出來了。

//應爲客戶端是普通用戶,因此使用user請求,channel:渠道,id:設備id,age:年齡。
//這些均可以做爲後臺賽選發送對象的依據,若是須要擴充,能夠在這裏加入其餘參數,後臺賽選時處理一下便可
 val request = Request.Builder().url("ws://xxx.xxx.xxx.xxx:8080/user?channel=0&id=0&age=22").build()
            val socketListener = WebSocketCallback()

            var mOkHttpClient = OkHttpClient.Builder()
                //設置讀取超時時間
                .readTimeout(3, TimeUnit.SECONDS)
                //設置寫的超時時間
                .writeTimeout(3, TimeUnit.SECONDS)
                //設置鏈接超時時間
                .connectTimeout(3, TimeUnit.SECONDS)
                .build()

            mOkHttpClient!!.newWebSocket(request, object:WebSocketCallback(){
                //鏈接成功
                override fun onOpen(webSocket: WebSocket?, response: Response?) {
                    super.onOpen(webSocket, response)
                    text!!.setText("鏈接狀態:鏈接成功")
                }

                //收到消息
                override fun onMessage(webSocket: WebSocket?, text: String?) {
                    super.onMessage(webSocket, text)
                    content!!.setText(text)
                    //在這裏能夠啓動彈窗啓動notification,後端傳送的數據最好是json,這樣容易解析
                }

                //關閉後
                override fun onClosed(webSocket: WebSocket?, code: Int, reason: String?) {
                    super.onClosed(webSocket, code, reason)
                    text!!.setText("鏈接狀態:鏈接失敗")
                }

                //關閉時
                override fun onClosing(webSocket: WebSocket?, code: Int, reason: String?) {
                    super.onClosing(webSocket, code, reason)
                    Log.e("日誌", "連接關閉中")
                }

                //鏈接異常
                override fun onFailure(webSocket: WebSocket?, t: Throwable?, response: Response?) {
                    super.onFailure(webSocket, t, response)
                }

            })
            //關閉鏈接服務
            //mOkHttpClient.dispatcher().executorService().shutdown()
複製代碼

到上面就已經徹底結束了,效果就是文章開頭的gif圖,固然,這裏我忽略前端管理後臺的搭建,應爲這個不是文章重點,因此忽略掉了。前端管理後臺實際上我使用的是AdminLTE-3.0.0,讓後寫一些js和後端交互傳遞一下數據就好了。

總結

優勢:

  • 可擴展性強,咱們須要什麼推送條件,補齊一下就行了,若是使用友盟推送等第三方推送,每每會發現他們提供給咱們的推送條件並不徹底是咱們須要的。
  • 推送目標精確。目標越明確,越容易達到本身產品的推廣目的
  • 方便獲取用分佈信息。由於在鏈接websocket時會有傳遞的參數,這些參數夠多的話能夠方便的知道用戶構成是怎樣的
  • 省錢,友盟這些第三方推進確定避免不了收費的狀況,若是是本身的推送後臺,則能夠省去這筆費用,同時一些關鍵信息也是掌握在本身手頭上的。

缺點

  • 第一:須要對推送系統進行開發維護,二第三方的直接接入便可使用。
  • 第二:當在線用戶過多時可能影響服務器性能,畢竟每一個在線用戶就是一個實體,若是有上千萬個在線用戶,也就意味着服務器必須建立成千上完個實體,服務器吃不吃得消還真很差說。這裏我尚未作過壓力測試,也很差知道究竟會對服務器有多大的影響。
  • 第三:推送不必定能夠準確命中目標。若是目標設備要收到推送,則設備必須處於在線狀態。對於安卓手機而言,進程保活的手段是挺多的,可是不可能100%有效,除非像阿里,騰訊那樣是獨角獸公司,能夠被手機系統列入白名單。
相關文章
相關標籤/搜索