Direct Messages(2)
🍔 핵심 내용
🥑 실시간 채팅 기능을 만들어 보자.
이 실시간 기능은, 채팅 뿐만 아니라 좋아요 실시간 반영 같이 쓰일 수 있다.
일단은, 스터디용으로 만들어보자. 실제 배포용으로는 유료로 한다고 한다. (배포용으로는 graphql redis 서버 사용)
🍔 코드 리뷰
🥑 pubsub.js
import { PubSub } from "apollo-server-express";
const pubsub = new PubSub();
export default pubsub;
publish 하기 위한 세팅
🥑 constant.js
export const NEW_MESSAGE = "NEW_MESSAGE";
상수 값 별도 관리
🥑 roomUpdates.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type Subscription {
roomUpdates: Message
}
`;
roomUpdate(실시간 채팅 룸 업데이트 값 확인)에 대한 타입을 정의
🥑 roomUpdates.resolvers.js
import { NEW_MESSAGE } from "../../constant";
import pubsub from "../../pubsub";
export default {
Subscription: {
roomUpdates: {
subscribe: () => pubsub.asyncIterator(NEW_MESSAGE),
},
},
};
"NEW_MESSAGE" 라는 값을 어디선가 호출하면 해당 함수가 발동
🥑 server.js
require("dotenv").config();
import http from "http";
import express from "express";
import logger from "morgan";
import { ApolloServer } from "apollo-server-express";
import { typeDefs, resolvers } from "./schema";
import { getUser } from "./users/users.utils";
import pubsub from "./pubsub";
const PORT = process.env.PORT;
const apollo = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
if (req) {
return {
loggedInUser: await getUser(req.headers.token),
};
}
},
});
const app = express();
app.use(logger("tiny"));
apollo.applyMiddleware({ app });
app.use("/static", express.static("uploads"));
const httpServer = http.createServer(app);
apollo.installSubscriptionHandlers(httpServer);
httpServer.listen(PORT, () => {
console.log(`🚀 Server is running http://localhost:${PORT}/graphql ✅`);
});
큰 개념은, WS(웹소켓) 서버와 http 서버를 같이 써야 한다. 기존 http 서버는 request와 response 두개 밖에 못하고 웹 소켓 서버는 실시간이 가능하다. 따라서 서버에서는 이 둘을 같이 섞어야 한다.
1) req 값은 WS 에서 이해 할 수 없기 때문에 조건문으로 처리한다.
🥑 sendMessage.resolvers.js
import client from "../../client";
import { NEW_MESSAGE } from "../../constant";
import pubsub from "../../pubsub";
import { protectedResolver } from "../../users/users.utils";
export default {
Mutation: {
sendMessage: protectedResolver(
async (_, { payload, roomId, userId }, { loggedInUser }) => {
let room = null;
if (userId) {
const user = await client.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
},
});
if (!user) {
return {
ok: false,
error: "This user does not exist.",
};
}
room = await client.room.create({
data: {
users: {
connect: [
{
id: userId,
},
{
id: loggedInUser.id,
},
],
},
},
});
} else if (roomId) {
room = await client.room.findUnique({
where: {
id: roomId,
},
select: {
id: true,
},
});
if (!room) {
return {
ok: false,
message: "Room not found",
};
}
}
const message = await client.message.create({
data: {
payload,
room: {
connect: {
id: room.id,
},
},
user: {
connect: {
id: loggedInUser.id,
},
},
},
});
pubsub.publish(NEW_MESSAGE, { roomUpdates: { ...message } }); //<--추가
return {
ok: true,
};
}
),
},
};
pubsub.publish 를 통해, roomUpdates에 실시간 업데이트 값을 전달 한다.
🍔 핵심 내용
🥑 실시간 채팅 기능에 필터 기능을 추가 하자.
특정 roomId에 대해서만 실시간 리스닝을 하는 기능을 추가해야 한다.
🍔 코드 리뷰
🥑 roomUpdates.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type Subscription {
roomUpdates(id: Int!): Message
}
`;
id 인자 값 추가 (특정 roomId 값을 받기 위해)
🥑 roomUpdates.resolvers.js
import { withFilter } from "graphql-subscriptions";
import { NEW_MESSAGE } from "../../constant";
import pubsub from "../../pubsub";
export default {
Subscription: {
roomUpdates: {
subscribe: withFilter(
() => pubsub.asyncIterator(NEW_MESSAGE),
({ roomUpdates }, { id }) => {
return roomUpdates.roomId === id;
}
),
},
},
};
withFilter을 사용하여 필터기능을 활성화 한다. 첫번째값에는 실제 들어갈 메시지 값이 들어가고 두번째 값에는 2개의 인자값이 들어가는데, 첫번째 값은 payload값, 두번째 값은 유저가 넣은 인자 값이 들어 간다. 아래 그 결과 값을 확인하여, 유저가 넣은 인자 값 id가 해당 업데이트 되어야 할 roomId와 일치하면 리스닝 되는 식으로 만들어 주자.
(withFilter값의 리턴 값은 항상 boolean 값이 되어야 함)
**참고
🍔 핵심 내용
🥑 없는 roomId는 리스닝 하지 않기
존재 하지 않는 roomId를 리스닝 하는 경우 에러 메시지가 나와야 한다.
🍔 코드 리뷰
🥑 roomUpdates.resolvers.js
import { withFilter } from "apollo-server";
import client from "../../client";
import { NEW_MESSAGE } from "../../constants";
import pubsub from "../../pubsub";
export default {
Subscription: {
roomUpdates: {
subscribe: async (root, args, context, info) => {
const room = await client.room.findUnique({
where: {
id: args.id,
},
select: {
id: true,
},
});
if (!room) {
throw new Error("You shall not see this.");
}
return withFilter(
() => pubsub.asyncIterator(NEW_MESSAGE),
({ roomUpdates }, { id }) => {
return roomUpdates.roomId === id;
}
)(root, args, context, info);
},
},
},
};
이해하기 어렵다. 그냥 눈으로 보고 읽자.
오케이. 어찌 됐든 지금 까지 만든걸로 보면 실시간 기능과 필터기능 및 없는 roomId가 들어오면 에러 메시지 띄우기 까지 했다. 마지막으로는 인증 부분 하나 남아 있다. 이건 다음 내용에!
🍔 핵심 내용
🥑 인증 관련
로그인한 유저가 포함 되어 있는 roomId가 아니면 에러 메시지를 던져 주자.
🍔 코드 리뷰
🥑 server.js
require("dotenv").config();
import http from "http";
import express from "express";
import logger from "morgan";
import { ApolloServer } from "apollo-server-express";
import { typeDefs, resolvers } from "./schema";
import { getUser } from "./users/users.utils";
const PORT = process.env.PORT;
const apollo = new ApolloServer({
resolvers,
typeDefs,
context: async (ctx) => {
if (ctx.req) {
return {
loggedInUser: await getUser(ctx.req.headers.token),
};
} else {
const {
connection: { context },
} = ctx;
return {
loggedInUser: context.loggedInUser,
};
}
},
subscriptions: {
onConnect: async ({ token }) => {
if (!token) {
throw new Error("You can't listen.");
}
const loggedInUser = await getUser(token);
return {
loggedInUser,
};
},
},
});
const app = express();
app.use(logger("tiny"));
apollo.applyMiddleware({ app });
app.use("/static", express.static("uploads"));
const httpServer = http.createServer(app);
apollo.installSubscriptionHandlers(httpServer);
httpServer.listen(PORT, () => {
console.log(`🚀Server is running on http://localhost:${PORT} ✅`);
});
websoket에서는 subscriptions 를 통해서 token 값을 가지고 올 수 있고 이에 해당 user값을 가지고 올 수 있다. 해당 user값은 context로 사용 된다. (http에서의 context값은 req 값이 있고, ws에는 req이 없음)
WS에서 context값으로 user값(loggedInUser)을 받아 내면 해당 값을 활용하여, roomUpdates.resolvers 값에 활용해야 한다. (해당 유저가 db에서 인자로 받은 roomId값에 속하는지 검증)
🥑 roomUpdates.resolvers.js
import { withFilter } from "apollo-server";
import client from "../../client";
import { NEW_MESSAGE } from "../../constants";
import pubsub from "../../pubsub";
export default {
Subscription: {
roomUpdates: {
subscribe: async (root, args, context, info) => {
const room = await client.room.findFirst({
where: {
id: args.id,
users: {
some: {
id: context.loggedInUser.id,
},
},
},
select: {
id: true,
},
});
if (!room) {
throw new Error("You shall not see this.");
}
return withFilter(
() => pubsub.asyncIterator(NEW_MESSAGE),
async ({ roomUpdates }, { id }, { loggedInUser }) => {
if (roomUpdates.roomId === id) {
const room = await client.room.findFirst({
where: {
id,
users: {
some: {
id: loggedInUser.id,
},
},
},
select: {
id: true,
},
});
if (!room) {
return false;
}
return true;
}
}
)(root, args, context, info);
},
},
},
};
subscribe는 2개의 인자 값을 받는데 첫번째 인자값은 리스닝 전에, 두번째 인자값(withFilter)은 sendMessage에서 트리거가 발동된 후에 활용 된다. 위 내용은 리스닝 이전, 이후 모두 검증 될 수 있도록 해놓은 세팅 값이다.
인자로 받은 roomId로 해당 room이 있는지 확인하고, 있다면 해당 room의 users배열에 context로 받은 loggedInUser가 있는지 여부를 확인한다.