用Haskell实现一个聊天频道

我一定是极其无聊,所以才写了两百多行的恶心代码。然而其中还是有不少心血的,故记录下来以供以后参考。

先介绍下功能,使用websocket 作为通道,warp 为其容器,客户端使用浏览器。通过url可以直接进入频道,频道内有最近的聊天记录,在线人列表。

Haskell实现websocket服务端 ,遇到几个关键问题:

  1. 保持socket的活跃,websockets包带有withPingThread 函数可以保障活跃。
  2. 线程的生命周期管理,比如关闭浏览器了,心跳线程,监听消息线程当然也要关闭。
  3. 线程间的通信,如A向B发送了一条消息以及维护最近消息和在线人数列表,它们都是在不同的线程里面。

线程生命周期管理可以用下面几个函数:

线程间通信可以使用Chan 作为广播

代码及实现思路:

开启服务与基础线程:

chatHeadquarters 为维护最近消息列表、频道列表、在线人列表线程

W.runSettingswarp提供的运行 httpwebsockets的容器 ,当其出现灾难级别的错误意味着整个服务都挂了,chatHeadquarters线程就没有意义了,故 cancel chatAsync


当用户建立连接:

每一个客户端建立连接都为其创建一个线程内部通信的通道

channel <- liftIO $ dupChan channelHead

名字验证逻辑在前面提到的chatHeadquarters线程内完成,因为该线程维护着最全的信息可以判断重名与否,验证后的结果通过channel发送过来。

writeChan channel $ Chat.Online clientId 为广播一个人来建立连接了,但只有chatHeadquarters线程处理这条消息 ,验证名字是否合法并把该频道内的最近消息和在线人员列表广播回来。

a <- async $ waitInitialInfo clientId channel 为新开启一个线程,等待接收chatHeadquarter线程广播回来的信息。 这两个线程的代码实现会放到最后。


由于等待时间未知,需要一个超时处理,如果超时就要取消等待线程,这里利用waitEitherCancel巧妙的实现。

接下来就是处理验证结果:

错误就关闭连接并且带着原因。

正确就进入主逻辑:

W.withPingThread conn 30 heart $ do 新开启线程每30s向客户端发送Ping消息并调用下heart,其中heart参数是一个 IO () ,最后一个参数为主逻辑,如果挂掉Ping线程也会相应挂掉。

a <- async $ forever $ listenBroadcast channel conn clientId 监听其它线程广播过来的消息,并处理——有消息就通过websocket发送给客户端,同时判断消息是否属于本频道的,若不属于则忽略。

(forever $ listenClient channel conn clientId) 监听客户端发过来的消息,然后通过channel广播出去。

同时做好异常处理,controlException 如果挂掉友好关闭客户端连接,并终止listenBroadcast线程。

至此大体思路讲解完毕了,后面给出各个线程 实现,比较无聊可以不用往后面看了。

chatHeadquarter 线程:

chatHeadquarters chatChannel = do
  chatInfosRef <- newIORef []
  liveCheck <- async $ forever $ do
    threadDelay (oneSec * 60)
    time <- Kit.getCurrentTime
    let crstamp = Kit.mkTimestamp time
    chatInfos <- readIORef chatInfosRef
    forM_ chatInfos $ \ info -> do
      forM_ (Chat.onlineMember info)  $ \ (id,timestamp) -> do
        if crstamp - timestamp > 40*1000 then do
          writeChan chatChannel $ Chat.Offline (Chat.chatName info,id) "Timeout"
        else pure ()
  let handException action = do
        handle' `handle` action
        where handle' e = do
                cancel liveCheck
                $err' $ show (e :: SomeException)
  handException $ forever $ do
    a <- readChan chatChannel
    case a of
      (Chat.Online clientId@(chatName,id)) -> do
        chatInfos <- readIORef chatInfosRef
        r <- modifyOrCreateChat chatName chatInfos $ \ info -> do
          time <- Kit.getCurrentTime
          let timestamp = Kit.mkTimestamp time
          let info' = Chat.over_onlineMember (((id,timestamp):) . filter ((/=id) . fst)) info
          writeChan chatChannel $ Chat.InitialInfo clientId info'
          pure info'
        writeIORef chatInfosRef r
      (Chat.Ping (chatName,id)) -> do
        chatInfos <- readIORef chatInfosRef
        r <- modifyOrCreateChat chatName chatInfos $ \ info -> do
          time <- Kit.getCurrentTime
          let timestamp = Kit.mkTimestamp time
          let member = List.find ((==id) . fst) $ Chat.onlineMember info
          if member == Nothing then do
            --此处为处于某种原因而踢出在线列表,但连接仍然有效并且在Ping.
            --同一个ID多个端登录,其中一端下线也会出现这种情况
            let info' = Chat.over_onlineMember ((id,timestamp):) info
            writeChan chatChannel $ Chat.Online (chatName,id)
            pure info'
          else do
            let updateTime (id',_) | id' == id = (id,timestamp)
                updateTime a = a
            let info' = Chat.over_onlineMember (map updateTime) info
            pure info'
        writeIORef chatInfosRef r
      (Chat.Send chatName message) -> do
        chatInfos <- readIORef chatInfosRef
        r <- modifyOrCreateChat chatName chatInfos $ \ info -> do
          pure $ Chat.over_recentMessages (take 100 . (message:)) info
        writeIORef chatInfosRef r
      (Chat.Offline (chatName,id) reson) -> do
        chatInfos <- readIORef chatInfosRef
        let ignore (id',_) =  id' /= id
        r <- modifyOrCreateChat chatName chatInfos $ \ info -> do
          pure $ Chat.over_onlineMember (filter ignore) info
        writeIORef chatInfosRef r
      _ -> pure ()

listenClient 监听客户端消息线程:

listenBroadcast 监听广播线程:

pingSystem 心跳广播线程


以下为客户端(React)的实现,更无聊不建议看

websocket本地环境代理实现:

chatSockets.ts :

manpage :

import { Stack,chakra,useColorMode,Text, Box, IconButton, ChakraProps, Center, useColorModeValue, Tooltip, useToken, useTheme, VStack, BoxProps, TextProps, TextareaProps, useClipboard, useToast, UseModalProps, Button, Input, useDisclosure } from '@chakra-ui/react'
import type { NextPage } from 'next'
import {CopyIcon, MoonIcon,SmallAddIcon,SunIcon} from '@chakra-ui/icons'
import React, { useEffect, useRef, useState } from 'react'
import FlexInputAutomatically from '@c/FlexInputAutomatically'
import Sending from "src/icons/Sending"
import { useRouter } from 'next/router'
import { cancel, chatForChannel, doName } from 'src/channel/chatSocket'
import {
  Modal,
  ModalOverlay,
  ModalContent,
  ModalHeader,
  ModalFooter,
  ModalBody,
  ModalCloseButton,
} from '@chakra-ui/react'

type SwitchChannelProps = {
  isOpen: boolean,
  onClose: UseModalProps["onClose"],
  onOk: (a:string) => void
}
let SwitchChannel : React.FC<SwitchChannelProps> = props => {
  let [channel,setChannel] = useState({
    value: "",
    isInvalid: false
  })
  let onOk : React.MouseEventHandler<HTMLButtonElement> = e => {
    if(channel.value == ""){
      setChannel(a=>({...a,isInvalid:true}))
    }else{
      props.onOk(channel.value)
    }
  }
  let onInput : React.FormEventHandler<HTMLInputElement> = e => {
      if(e.currentTarget.value == ""){
        setChannel(oldV => {
          return {...oldV,value:""}
        })
      }else{
        setChannel({value:e.currentTarget.value,isInvalid:false})
      }
  }
  return <Modal isOpen={props.isOpen} onClose={props.onClose}>
  <ModalOverlay />
  <ModalContent>
    <ModalHeader>切换频道</ModalHeader>
    <ModalCloseButton />
    <ModalBody>
      <Input variant='flushed'
      onInput={onInput}
      isInvalid={channel.isInvalid}
      value={channel.value} placeholder='输入频道名称' />
    </ModalBody>

    <ModalFooter>
      <Button colorScheme='blue' mr={3} onClick={props.onClose}>
        关闭
      </Button>
      <Button onClick={onOk} variant='ghost'>确定</Button>
    </ModalFooter>
  </ModalContent>
</Modal>
}

let BoxOut : React.FC<{children: React.ReactElement[]}> = props => {
  return <Box 
  width={["100vw",null,"xl"]}
  margin="auto" 
  minH="100vh"
  border="solid"
  borderColor={useColorModeValue("gray.200","gray.500")}
  borderWidth="1px"
  borderRadius="xl"
  paddingTop="84px"
  paddingBottom="78px"
  overflow="hidden"
  borderBottom="none">
    {props.children}
  </Box>
}
type MessageProps = {
  message: any,
  type: "me" | "other"
}
let Message : React.FC<MessageProps> = props => {
  let me = props.type == "me"
  let boxSetting : BoxProps
  let toolSetting : TextProps
  function formatDate(){
    let d = new Date(props.message.time)
    return d.toLocaleString()
  }
  if(me){
    boxSetting = {
      mr : "2",
      bg : useColorModeValue("teal.200","teal.500")
    }
    toolSetting = {
      right: "0"
    }
  } else {
    boxSetting = {
      ml : "2",
      bg : useColorModeValue("gray.200","gray.500")
    }
    toolSetting = {
      left: "0"
    }
  }
  return <VStack
  w="full"
  alignItems={me ? "flex-end" : "flex-start"}
  >
    <Box
    {...boxSetting}
    borderRadius="xl"
    padding="2"
    minW="20"
    maxW={["350px","500px"]}
    position="relative"
    >
      <Text
      position="absolute"
      top="-20px"
      {...toolSetting}
      fontSize="xs" color={useColorModeValue("gray.500","whiteAlpha.500")}>{props.message.sender}</Text>
      <Text fontSize="md" whiteSpace="pre-line" title={formatDate()}>{props.message.content}</Text>
    </Box>
  </VStack>
}
let isMobile = () => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const TextS : NextPage = () => {
  let router = useRouter()
  
  let [message,setMessage] = useState("")
  let [listUpdatedVersion,setListUpdatedVersion] = useState(0)
  let [list,setList] = useState([] as {content:string,sender:string,time:number}[])
  let [members,setMembers] = useState([] as [string,number][])
  let [me,setMe] = useState("")
  let [chatWS,setChatWS] = useState(null as WebSocket | null)
  let toast = useToast()
  
  function onMessageFromServer(data:{tag:string,contents:any},ws:WebSocket){
    console.log("onMessage",data)
    if(data.tag == "CInitialInfo"){
      setChatWS(ws)
      setList(data.contents.recentMessages.reverse())
      setListUpdatedVersion(Date.now())
      setMembers(data.contents.onlineMember)
    } else if(data.tag=="CIllegeData"){
      toast({
        status: "warning",
        description: "非法输入",
        isClosable: true
      })
    } else if(data.tag == "CMessage"){
      setList(list => list.concat([data.contents]).slice(0,1000))
      setListUpdatedVersion(Date.now())
    } else if(data.tag == "COffLine"){
      let a = data.contents[0][1]
      // if(a != me) {
       setMembers(members => members.filter(([id])=>id != a))
      // }
    } else if(data.tag == "COnline"){
      console.log("COnline-members",members.length)
      let a = data.contents[1]
      setMembers(members => members.filter(([id])=>id != a).concat([[a,Date.now()]]))
    }
  }
  useEffect(()=>{
    if(listUpdatedVersion == 0){
      void null
    }else{
      window.scrollTo(0,document.body.scrollHeight)
    }
  },[listUpdatedVersion])
  let {colorMode,toggleColorMode} = useColorMode()
  let ColorModeIcon = colorMode == "light" ? <MoonIcon />  : <SunIcon />
  let bg = useColorModeValue("whiteAlpha.900","blackAlpha.200") 
  let sendAction = () => {
    if(message == ""){
      void null
    } else {
      chatWS?.send(JSON.stringify({
        tag: "CMessage",
        contents:{
          content:message,
          sender: "=",
          time:0,
        }
      }))
      setMessage("")
    }
  }
  let messageInputOnKeyDown : TextareaProps["onKeyDown"] = e => {
    if(e.shiftKey == false && e.code == "Enter" && isMobile() == false){
      e.preventDefault()
      sendAction() 
    }else{
      void null
    }
  }
  let [uri,setUri] = useState(router.pathname)
  let { hasCopied, onCopy } = useClipboard(uri)
  useEffect(()=>{
    setUri(location.href)
  },[router.query.id])
  useEffect(()=>{
    if(router.query.id == null) return
    setMe(doName()) 
    let ws = chatForChannel(router.query.id as string,(ws,e)=>{
      onMessageFromServer(JSON.parse(e.data),ws)
    })
    return ()=>{
      cancel(ws)
    }
  },[router.query.id])
  useEffect(()=>{
    if(hasCopied){
      toast({
        status: "success",
        position: "top",
        isClosable: true,
        description: "复制成功," + uri
      }) 
    }
  },[hasCopied])
  let { isOpen, onOpen, onClose } = useDisclosure()
  let onConfirm: SwitchChannelProps["onOk"] = e => {
    router.push("/channel/" + e)
    onClose()
  }
  return (
    <BoxOut>
      <SwitchChannel isOpen={isOpen} onClose={onClose} onOk={onConfirm} />
      <Stack
      direction={"row"}
      zIndex="docked"
      bg={useColorModeValue("gray.200","gray.500")}
      position="fixed"
      w={["100vw",null,"xl"]}
      padding="4"
      top="0"
      alignItems={"center"}>
        <Text fontSize={"md"}>
          {router.query.id}
        </Text>
        <Text fontSize={"xs"} ml="px">
          ({members.length}人在线)
        </Text>
        <Stack direction="row" flexGrow={1} justifyContent="flex-end">
          <Tooltip label="复制分享">
            <IconButton 
            onClick={onCopy}
            aria-label='分享' size={"sm"} icon={<CopyIcon />} />
          </Tooltip>
          <IconButton
          title="更换一个频道"
          onClick={onOpen}
          aria-label='更换一个频道' size={"sm"} icon={<SmallAddIcon />} />
          <IconButton
          title="高亮/夜间模式"
          aria-label='高亮/夜间模式' size={"sm"} onClick={toggleColorMode} icon={ColorModeIcon} />
        </Stack>
      </Stack>
      <VStack spacing={12}>
        {list.map(a=><Message message={a} key={a.sender+a.time} type={a.sender == me ? "me" : "other"} />)}
      </VStack>
      <Box
      position="fixed"
      w={["100vw",null,"xl"]}
      bottom={0}
      zIndex="docked"
      bg={useColorModeValue("gray.200","gray.500")}
      ml="-1px"
      padding="8px 16px"
      >
        <FlexInputAutomatically
        bg={bg}
        borderColor={bg}
        aria-label="输入信息"
        value={message}
        autoFocus={true}
        placeholder="Enter发送/Shift-Enter换行"
        pr="9"
        onKeyDown={messageInputOnKeyDown}
        onInput={message=>setMessage(message)}
         />
        <Sending
        position="absolute"
        zIndex="docked"
        boxSize={6}
        onClick={sendAction}
        right="7"
        cursor="pointer"
        top="4"
        />
      </Box>
    </BoxOut>
  )
}

export default TextS 

深圳SEO优化公司咸宁网站改版报价陇南网站优化排名价格营口网站优化推广报价广东建网站报价迁安网站建设价格永州网站制作设计哪家好榆林关键词按天扣费报价玉溪设计公司网站哪家好白城网站建设设计价格凉山百度竞价包年推广推荐大鹏优化南京网站建设设计报价襄樊SEO按天收费荆州网站推广工具公司平凉网站优化排名推荐榆林网站改版价格长葛高端网站设计多少钱抚顺设计公司网站公司台州推广网站推荐清远网站关键词优化推荐飞来峡seo排名无锡关键词排名包年推广报价宝鸡SEO按效果付费推荐岳阳网络推广推荐济源网站优化哪家好垦利SEO按天计费哪家好南山网站改版推荐丹竹头网页设计哪家好遂宁seo排名价格贵港seo排名哪家好歼20紧急升空逼退外机英媒称团队夜以继日筹划王妃复出草木蔓发 春山在望成都发生巨响 当地回应60岁老人炒菠菜未焯水致肾病恶化男子涉嫌走私被判11年却一天牢没坐劳斯莱斯右转逼停直行车网传落水者说“没让你救”系谣言广东通报13岁男孩性侵女童不予立案贵州小伙回应在美国卖三蹦子火了淀粉肠小王子日销售额涨超10倍有个姐真把千机伞做出来了近3万元金手镯仅含足金十克呼北高速交通事故已致14人死亡杨洋拄拐现身医院国产伟哥去年销售近13亿男子给前妻转账 现任妻子起诉要回新基金只募集到26元还是员工自购男孩疑遭霸凌 家长讨说法被踢出群充个话费竟沦为间接洗钱工具新的一天从800个哈欠开始单亲妈妈陷入热恋 14岁儿子报警#春分立蛋大挑战#中国投资客涌入日本东京买房两大学生合买彩票中奖一人不认账新加坡主帅:唯一目标击败中国队月嫂回应掌掴婴儿是在赶虫子19岁小伙救下5人后溺亡 多方发声清明节放假3天调休1天张家界的山上“长”满了韩国人?开封王婆为何火了主播靠辱骂母亲走红被批捕封号代拍被何赛飞拿着魔杖追着打阿根廷将发行1万与2万面值的纸币库克现身上海为江西彩礼“减负”的“试婚人”因自嘲式简历走红的教授更新简介殡仪馆花卉高于市场价3倍还重复用网友称在豆瓣酱里吃出老鼠头315晚会后胖东来又人满为患了网友建议重庆地铁不准乘客携带菜筐特朗普谈“凯特王妃P图照”罗斯否认插足凯特王妃婚姻青海通报栏杆断裂小学生跌落住进ICU恒大被罚41.75亿到底怎么缴湖南一县政协主席疑涉刑案被控制茶百道就改标签日期致歉王树国3次鞠躬告别西交大师生张立群任西安交通大学校长杨倩无缘巴黎奥运

深圳SEO优化公司 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化