코딩/React

ElectronJS 일렉트론 강좌 3편 SQLite3 sql.js

드리프트 2021. 9. 23. 16:14
728x170

안녕하세요?

 

지난 시간에 이어 3편 진행토록 하겠습니다.

 

1편: https://cpro95.tistory.com/500

 

ElectronJS 일렉트론 강좌 1편 SQLite3 sql.js

안녕하세요? 오늘은 Electron 강좌를 시작해 볼까 합니다. ElectronJS는 웹상의 기술인 HTML, CSS, Javascript로 데스크톱 애플리케이션을 만들 수 있는 NodeJS 패키지인데요. HTML, CSS, Javascript로 작동되다..

cpro95.tistory.com

 

2편 : https://cpro95.tistory.com/185

 

최신버전으로 React + Typescript + Electron 개발하기

안녕하세요? 예전에는 React를 그냥 Javascipt로 개발했지만 최근에는 점점 더 Typescript가 대세가 되고 있습니다. 그래서 개인적으로 Electron 앱을 취미로 개발하고 있는데 React와 Typescript를 적용해서

cpro95.tistory.com

 

3편에서는 우리의 일렉트론 앱을 마무리해 보겠습니다.

 

일단 React에서 라우팅 하기 위한 react-router-dom을 설치하겠습니다.

 

npm i react-router-dom

 

 

1. 라우팅 설정하기

 

React 앱의 라우팅은 react-roter-dom을 이용하는데요.

 

일반적인 Browser라면 BrowserRouter를 이용하면 되지만 일렉트론 앱에서는 HashRouter를 이용해야 합니다.

 

index.js파일에서 전체 컴포넌트를 HashRouter로 감싸도록 합시다.

 

 

/src/index.js

 

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
// import reportWebVitals from "./reportWebVitals";
import { ChakraProvider } from "@chakra-ui/react";
import Layout from "./components/Layout";
import { HashRouter } from "react-router-dom";

ReactDOM.render(
  <React.StrictMode>
    <HashRouter>
      <ChakraProvider resetCSS>
        <Layout>
          <App />
        </Layout>
      </ChakraProvider>
    </HashRouter>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
// reportWebVitals();

 

그러고 나서 App.js 파일에 라우팅을 넣어보겠습니다.

 

/src/App.js

 

import React from "react";
import { Switch, Route } from "react-router-dom";

// Screens
import Home from "./screens/Home";
import Latest from "./screens/Latest";
import Search from "./screens/Search";
import Ratings from "./screens/Ratings";
import Details from "./screens/Details";

const App = () => (
  <Switch>
    <Route exact path="/" component={Home} />
    <Route exact path="/search" component={Search} />
    <Route exact path="/latest" component={Latest} />
    <Route exact path="/ratings" component={Ratings} />
    <Route exact path="/details/:id" component={Details} />
  </Switch>
);

export default App;

 

react-router-dom을 이용해서 Switch와 Route를 이용해서 전체적인 앱의 라우팅 구조를 짰습니다.

 

Home 컴포넌트가 root가 되겠죠.

 

그리고, 각각의 컴포넌트를 Screen이라고 보고 screens 폴더 밑에 하나씩 만들어 봅시다.

 

먼저 Home 컴포넌트입니다.

 

 

/src/screens/Home.js

 

import React from "react";
import { Link } from "react-router-dom";

import { Flex, VStack, Heading, Stack, Button } from "@chakra-ui/react";

function Home() {
  return (
    <Flex w="auto" justify="center" p={[0, 10, 10]}>
      <VStack>
        <Heading>Welcome Home!</Heading>
        <Heading>Electon + React + Sqlite3 + Chakra-UI</Heading>
        <Flex h="25vh" align="center" justify="center">
          <Stack direction="row" spacing={10}>
            <Link to="/search">
              <Button colorScheme="facebook">Search</Button>
            </Link>
            <Link to="/latest">
              <Button colorScheme="whatsapp">Latest</Button>
            </Link>
            <Link to="/ratings">
              <Button colorScheme="twitter">Ratings</Button>
            </Link>
          </Stack>
        </Flex>
      </VStack>
    </Flex>
  );
}

export default Home;

 

Home 컴포넌트는 원하시는 UI로 멋지게 만들어 보시기 바랍니다.

 

저는 Coding에 주력하다 보니까 각 Screen으로의 링크 버튼으로 Home 컴포넌트를 마무리했습니다.

 

그럼 먼저, Search 컴포넌트를 알아보겠습니다.

 

 

/src/screens/Search.js

 

import React, { useState } from "react";
import { Link } from "react-router-dom";

import {
  Flex,
  VStack,
  Heading,
  Text,
  Input,
  HStack,
  Box,
  Icon,
  Badge,
} from "@chakra-ui/react";
import { CheckIcon } from "@chakra-ui/icons";

function Search() {
  const [search, setSearch] = useState("");
  const [data, setData] = useState();

  const handleSearch = (e) => {
    setSearch(e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    window.myApi.send("search-query", search);
    setSearch("");
  };

  window.myApi.receive("sql-return-search", (data) => {
    // console.log(`Received data from main process`);
    // console.table(data);
    setData(data);
    window.myApi.removeListeners("sql-return-search");
  });

  return (
    <Flex w="auto" justify="center" p={[0, 10, 10]}>
      <VStack spacing={4}>
        <Heading>Search Movies</Heading>
        <Text>Search Movies with query.</Text>
        <form onSubmit={handleSubmit}>
          <Input
            w="300px"
            type="text"
            value={search}
            onChange={handleSearch}
            placeholder="Input query and press enter."
          ></Input>
        </form>
        {data ? <Text>Total - {data.length}</Text> : <></>}
        {data &&
          data.map((i) => (
            <HStack key={i.idMovie} align={"top"} p={2}>
              <Box color={"green.400"} px={2}>
                <Icon as={CheckIcon} />
              </Box>
              <HStack align={"start"}>
                <Link to={`/details/${i.idMovie}`}>
                  <Text fontWeight={600}>{i.c00}</Text>
                </Link>
                <Badge>{i.rating}</Badge>
              </HStack>
            </HStack>
          ))}
      </VStack>
    </Flex>
  );
}

export default Search;

 

 

 

Search 컴포넌트는 input form을 이용해서 SQLite Data를 Query 하고 그 내용을 리스트로 나타내는 컴포넌트입니다.

 

star라고 쳤을 때 나타나는 Data입니다.

 

그럼 어떻게 sql query 처리했을까요?

 

form의 handleSubmit 함수에서 window.myApi.send("search-query", search)로 일렉트론의 Main Process에 명령어 실행을 요청했습니다.

 

2편에서 봤듯이 preload.js 파일에 window.myApi.send 명령어가 있는데요.

 

전체적인 preload.js 파일입니다.

 

contextBridge.exposeInMainWorld 기능을 알고 싶으시면 2편 마지막 부분을 보시면 됩니다.

 

 

/public/preload.js

 

const { contextBridge, ipcRenderer } = require("electron");

// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("myApi", {
  send: (channel, data) => {
    // whitelist channels
    let validChannels = [
      "search-query",
      "latest-query",
      "ratings-query",
      "id-query",
    ];
    if (validChannels.includes(channel)) {
      ipcRenderer.send(channel, data);
    }
  },
  receive: (channel, func) => {
    let validChannels = [
      "sql-return-search",
      "sql-return-latest",
      "sql-return-ratings",
      "sql-return-id",
    ];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.on(channel, (event, ...args) => func(...args));
    }
  },
  removeListeners: (channel) => {
    let validChannels = [
      "sql-return-search",
      "sql-return-latest",
      "sql-return-ratings",
      "sql-return-id",
    ];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.removeAllListeners(channel);
    }
  },
});

preload.js 파일을 보시면 Main Process로 SQL 명령어 실행을 요청하는 메시지가 각각,

"search-query",

"latest-query",

"ratings-query",

"id-query",

입니다.

 

이름을 보시면 각 컴포넌트에서 사용한다고 추측할 수 있는데요. 마지막 id-query 메시지는 Details 컴포넌트에서 사용할 예정입니다.

 

특정 id의 Data 한 개만 가져오는 SQL 명령어를 요청하는 거죠.

 

그럼 /public/electron.js 파일의 Main Process에서의 코드를 볼까요?

 

ipcMain.on("search-query", (event, arg) => {
  // console.log('query from renderer : ', arg);
  var stmt = `select idMovie, c00, c01, c03, c08, c16, c19, c20, premiered, strPath,rating, uniqueid_value from movie_view where c00 like '%${arg}%' order by idMovie desc`;
  getData(stmt)
    .then((res) => event.sender.send("sql-return-search", res))
    .catch((error) => console.log(error));
});

ipcMain.on("latest-query", (event, arg) => {
  // console.log("query from renderer : ", arg);
  getData2(arg)
    .then((res) => event.sender.send("sql-return-latest", res))
    .catch((error) => console.log(error));
});

ipcMain.on("ratings-query", (event, arg) => {
  // console.log('query from renderer : ', arg);
  getData(arg)
    .then((res) => event.sender.send("sql-return-ratings", res))
    .catch((error) => console.log(error));
});

ipcMain.on("id-query", (event, arg) => {
  // console.log('query from renderer : ', arg);
  var stmt = `select idMovie, c00, c01, c03, c08, c16, c19, c20, premiered, strPath,rating, uniqueid_value from movie_view where idMovie=${arg}`;
  getData(stmt)
    .then((res) => event.sender.send("sql-return-id", res))
    .catch((error) => console.log(error));
});

각각의 query에 대해 ipcMain.on으로 대응했습니다.

 

그리고 Renderer Process로 리턴하는 메시지는 각각,

 

"sql-return-search",

"sql-return-latest",

"sql-return-ratings",

"sql-return-id",

 

입니다.

 

코드를 보시면 쉽게 이해할 수 있을 겁니다.

 

다음으로 Latest 컴포넌트를 알아보겠습니다.

 

 

/src/screens/Latest.js

 

import React, { useEffect, useState } from "react";
import { Link } from "react-router-dom";

import {
  Flex,
  VStack,
  Heading,
  Text,
  HStack,
  Icon,
  Badge,
  Box,
} from "@chakra-ui/react";

import { CheckIcon } from "@chakra-ui/icons";

function Latest() {
  const [data, setData] = useState();

  useEffect(() => {
    window.myApi.send(
      "latest-query",
      "select idMovie, c00, premiered, rating from movie_view order by premiered desc"
    );
  }, []);

  window.myApi.receive("sql-return-latest", (data) => {
    // data && console.log(`Received data from main process`);
    // console.table(data);
    setData(data);
    window.myApi.removeListeners("sql-return-latest");
  });

  return (
    <Flex w="auto" justify="center" p={[0, 10, 10]}>
      <VStack>
        <Heading>Latest Movies</Heading>
        <Text>Total : {data && data.length}</Text>

        {data &&
          data.map((i) => (
            <HStack key={i.idMovie} align={"top"} p={2}>
              <Box color={"green.400"} px={2}>
                <Icon as={CheckIcon} />
              </Box>
              <HStack align={"start"}>
                <Link to={`/details/${i.idMovie}`}>
                  <Text fontWeight={600}>{i.c00}</Text>
                </Link>
                <Badge>{i.rating}</Badge>
              </HStack>
            </HStack>
          ))}
      </VStack>
    </Flex>
  );
}

export default Latest;

Latest 컴포넌트는 SQL DB에서 가장 최근 걸로 요청한 코드입니다.

 

sql query는 바로 다음과 같습니다.

 

"select idMovie, c00, premiered, rating from movie_view order by premiered desc"

order by로 정렬시켰는데요, premiered 항목으로 정렬했고 desc로 최근께 앞으로 나오게 했습니다. 반대로 하시려면 asc라고 넣으시면 됩니다.

 

다른 코드도 Search.js 코드랑 비슷하니 쉽게 이해하실 수 있을 겁니다.

 

 

다음으로 Ratings 컴포넌트로 넘어가 보겠습니다.

 

 

/src/screens/Ratings.js

 

import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";

import {
  Flex,
  VStack,
  Heading,
  Text,
  HStack,
  Icon,
  Badge,
  Box,
} from "@chakra-ui/react";

import { CheckIcon } from "@chakra-ui/icons";

function Ratings() {
  const [data, setData] = useState();

  useEffect(() => {
    window.myApi.send(
      "ratings-query",
      "select idMovie, c00, c01, c08, c20, rating from movie_view order by rating desc limit 30"
    );
  }, []);

  window.myApi.receive("sql-return-ratings", (data) => {
    // console.log(`Received data from main process`);
    // console.table(data);
    setData(data);
    window.myApi.removeListeners("sql-return-ratings");
  });

  return (
    <Flex w="auto" justify="center" p={[0, 10, 10]}>
      <VStack>
        <Heading>Ratings</Heading>
        <Text>Total : {data && data.length}</Text>
        {data &&
          data.map((i) => (
            <HStack key={i.idMovie} align={"top"} p={2}>
              <Box color={"green.400"} px={2}>
                <Icon as={CheckIcon} />
              </Box>
              <HStack align={"start"}>
                <Link to={`/details/${i.idMovie}`}>
                  <Text fontWeight={600}>{i.c00}</Text>
                </Link>
                <Badge>{i.rating}</Badge>
              </HStack>
            </HStack>
          ))}
      </VStack>
    </Flex>
  );
}

export default Ratings;

 

 

역시 코드의 형태는 비슷합니다.

 

여기서는 SQL Query가 다음과 같습니다.

 

"select idMovie, c00, c01, c08, c20, rating from movie_view order by rating desc limit 30"

order by를 rating로 desc 방식으로 정렬했고 limit 30을 써서 30개만 나오게 했습니다.

 

limit 30을 빼면 전체 다 나옵니다.

 

그리고 마지막으로 Details 컴포넌트인데요.

 

Search, Latest, Ratings 컴포넌트에서 영화 타이틀을 누르면 넘어가는 페이지가 Details 페이지입니다.

 

라우팅은 아래처럼 /details/:id로 DB의 idMovie를 id로 넘겼습니다.

<Route exact path="/details/:id" component={Details} />

 

 

/src/screens/Details.js

 

import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";

import {
  Flex,
  VStack,
  Heading,
  Text,
  Image,
  Tag,
  Divider,
} from "@chakra-ui/react";

function Details() {
  const { id } = useParams();
  const [data, setData] = useState();

  useEffect(() => {
    window.myApi.send("id-query", id);
  }, [id]);

  window.myApi.receive("sql-return-id", (data) => {
    // console.log(`Received data from main process`);
    // console.log(data[0]);
    setData(data[0]);
    window.myApi.removeListeners("sql-return-id");
  });

  return (
    <Flex w="auto" justify="center" p={[0, 10, 10]}>
      <VStack spacing={4}>
        <Flex direction={["column", "column", "row"]} spacing={4}>
          <Image
            objectFit={"contain"}
            w={200}
            src={data && data.c08}
            mr={8}
            alt="poster"
          />
          <VStack spacing={4}>
            <Heading>{data && data.c00}</Heading>
            <Divider />
            <Text m={2}>{data && data.c01}</Text>
            <Divider />
            <Tag colorScheme="facebook">Date: {data && data.premiered}</Tag>
            <Tag colorScheme="whatsapp">Rating: {data && data.rating}</Tag>
          </VStack>
        </Flex>
        <Image src={data && data.c20} objectFit={"cover"} alt="fanart" />
      </VStack>
    </Flex>
  );
}

export default Details;

 

Details 컴포넌트는 라우팅의 파라미터로 id를 넘겨오는데요.

 

React에서 어떻게 이 id를 받아올까요?

 

바로 useParams 훅을 이용하시면 됩니다.

 

import { useParams } from "react-router-dom";

function Details() {
  const { id } = useParams();
  ....
  ....
  ....
}

그러면 id를 이용해서 DB에서 정보를 가져와야겠죠.

 

근데 Details 컴포넌트가 로드될 때 바로 id가 넘어오는 게 아니고, useParams()를 실행했을 때 id 값이 넘어옵니다.

 

그 이전까지는 데이터가 없는 상태이죠.

 

그래서 useEffect 훅을 이용해서 id값이 변할 때마다 실행하는 아래 코드를 이용해서 DB에서 데이터를 가져오게 만들었습니다.

 

useEffect(() => {
    window.myApi.send("id-query", id);
  }, [id]);

이렇게 하면 id 값이 변할 때마다 window.myApi.send 명령어로 Main Process에게 SQL 명령어를 요청할 수 있게 됩니다.

 

UI 부분은 Flex를 이용해서 좀 멋지게 만들어 봤는데 영 아니네요.

 

프로그래머가 UI까지 하려고 하니 어렵네요.

 

이제 다 완성됐습니다.

 

그럼 패키지를 빌드해 볼까요?

 

패키지 빌드하기 전에 일렉트론 빌더를 설치해야 합니다.

 

제 글을 보셨으면 일렉트론 빌더는 벌써 설치하셨을 텐데요.

 

그럼 일렉트론 빌더의 옵션을 package.json 파일에 추가해보겠습니다.

 

{
  "name": "electron-sqlite3-demo",
  "description": "ElectronJS Sqlite3 Sql.js example",
  "version": "0.1.0",
  "private": true,
  "author": "cpro95",
  "main": "public/electron.js",
  "homepage": "./",
  "build": {
    "productName": "electron-test-app",
    "asar": true,
    "appId": "org.cpro95.electron-test-app",
    "files": [
      "dist",
      "db/*",
      "package.json"
    ],
    "extraResources": [
      "./extraResources/**"
    ]
  },
  "scripts": {
    "react-start": "react-scripts start",
    "react-build": "react-scripts build",
    "react-test": "react-scripts test",
    "react-eject": "react-scripts eject",
    "start": "concurrently \"cross-env NODE_ENV=development BROWSER=none yarn react-start\" \"wait-on http://localhost:3000 && electron .\"",
    "build": "yarn react-build && electron-builder",
    "release": "yarn react-build && electron-builder --publish=always"
  },
  "dependencies": {
    "@chakra-ui/icons": "^1.0.15",
    "@chakra-ui/react": "^1.6.7",
    "@emotion/react": "^11.4.1",
    "@emotion/styled": "^11.3.0",
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "electron-is-dev": "^2.0.0",
    "electron-store": "^8.0.1",
    "framer-motion": "^4.1.17",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-router-dom": "^5.3.0",
    "react-scripts": "4.0.3",
    "sql.js": "^1.6.1",
    "web-vitals": "^1.0.1",
    "xml2json-light": "^1.0.6"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "electron": "^14.0.1",
    "electron-builder": "^22.11.7",
    "wait-on": "^6.0.0"
  }
}

 

package.js 파일입니다. 위쪽에 build 항목이 있습니다.

 

"build": {
    "productName": "electron-test-app",
    "asar": true,
    "appId": "org.cpro95.electron-test-app",
    "files": [
      "dist",
      "db/*",
      "package.json"
    ],
    "extraResources": [
      "./extraResources/**"
    ]
  },

이게 일렉트론 빌더가 옵션으로 사용하는 부분입니다.

 

여기서 중요한 부분이 있습니다.

 

React 앱은 build 폴더에 컴파일되는데요.

 

React 앱과 상관없는 파일은 직접 추가해 줘야 합니다.

 

그 부분이 files 부분인데요.

 

여기서 "db/*"를 살펴보시면 우리의 db 폴더를 포함시키겠다는 뜻입니다.

 

이렇게 하면 일렉트론의 resources 폴더 밑 app 폴더에 자동으로 복사하게 됩니다.

 

그리고 마지막에 extraResources 항목이 있는데요.

 

강좌 1편에 보시면 우리의 MyVideos116.db 파일을 extraResources 폴더에 넣었습니다.

 

폴더 이름은 다르게 해도 됩니다.

 

단지 package.json 에 extraResources 항목에 그 폴더를 위와 같이 추가하면 일렉트론 빌더가 그 폴더를 통째로 복사하게 됩니다.

자 그럼, 위 그럼처럼 "npm run build"를 실행해 보시면 dist 폴더에 우리의 앱이 완성되어 있는 걸 볼 수 있습니다.

 

실행해 볼까요?

 

 

dist 폴더 밑에 mac 폴더가 있고 거기에 앱이 있습니다.

 

Search 컴포넌트도 잘 작동됩니다.

 

 

이상으로 일렉트론 강좌를 마치도록 하겠습니다.

 

 

PS. 지난 강의 중 잘못된 부분 정정합니다.

 

/public/electron.js 파일에서 preload.js를 불러오는 항목이 있는데요.

 

Development 모드일 때는 아무 문제없는데 Production 모드일 때 에러가 생겼었습니다.

 

그래서 다음과 같이 바꿔 주시면 됩니다.

 

 

/public/electron.js

 

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 900,
    height: 680,
    minWidth: 370,
    minHeight: 470,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      devTools: isDev,
      nativeWindowOpen: true,
      preload: path.resolve(__dirname, "preload.js"),
    },
  });

 

여기서 preload 부분을 path.resolve(__dirname, "preload.js")로 수정했습니다.

 

끝.

 

 

그리드형