Flutter, php, MySql 서버 연동 (feat. Docker & freezed)

someng·2022년 6월 5일
0

Flutter

목록 보기
7/8

0. Reference

오준석의 생존코딩 Youtube

1. Docker 설치

Docker 란?

Docker는 오픈 소스 컨테이너화 플랫폼이다.
이를 통해 개발자는 애플리케이션을 컨테이너로 패키징할 수 있다.

컨테이너애플리케이션 소스 코드를 임의의 환경에서 해당 코드의 실행에 필요한 운영체제(OS) 라이브러리 및 종속 항목과 결합하는 실행 가능한 표준 컴포넌트를 말한다.
컨테이너 기술은 애플리케이션 격리, 비용 효율적인 확장성, 폐기 가능성을 포함하여 VM의 모든 기능과 장점을 제공한다. 이는 기업들이 클라우드 네이티브 개발 및 하이브리드 멀티클라우드 환경으로 이전하면서 점점 더 유명세를 타고 있다.

Docker를 사용하지 않고도 컨테이너를 구축할 수 있지만, Docker 플랫폼을 이용하면 보다 손쉽고 보다 간편하며 보다 안전하게 컨테이너를 빌드, 배치 및 관리할 수 있다. Docker는 기본적으로 개발자가 단일 API를 통한 업무 절감 자동화와 간단한 명령을 사용하여 컨테이너를 빌드, 배치, 실행, 업데이트 및 중지할 수 있도록 해주는 툴킷이다.
[출처: https://www.ibm.com/kr-ko/cloud/learn/docker]

설치 사이트: https://docs.docker.com/desktop/mac/install/

1-1. 위 사이트로 이동

내 맥북은 Intel chip 사용하고 있으니 'Mac with Intel chip' 버튼을 눌러서 다운받았다. (M1 칩 사용 중인 맥북은 'Mac with Apple chip'누르면 됨)

1-2. dmg 파일 다운 받고나면, Applications으로 쇽 보내준다

1-3. Applications(응용프로그램)에 가보면 Docker App(앱)이 설치되어 있다!

Docker 실행 후, 터미널에서 Docker 버전을 확인해 보았다.

2. Docker 사용

2-1. docker 폴더 생성

2-2. 하위폴더 생성

아래와 같이 내 폴더에 Docker/php-mysql/이라는 디렉토리를 생성하였다.

2-3. VS code에서 방금 만든 폴더 열어 'dockerfile' 작성

: php 이미지 파일 업로드를 위한 파일

🌱 DockerFile

모든 Docker 컨테이너는 Docker 컨테이너 이미지의 빌드 방법에 대한 지시사항이 포함된 단순 텍스트 파일로 시작된다.
DockerFileDocker 이미지 작성 프로세스를 자동화한다.
기본적으로, 이는 이미지를 어셈블링하기 위해 Docker 엔진이 실행할 명령행 인터페이스(CLI) 명령어의 목록이다.

🌱 Docker 이미지

Docker 이미지에는 실행 가능한 애플리케이션 소스 코드는 물론, 애플리케이션 코드가 컨테이너로서 실행해야 하는 모든 툴, 라이브러리 및 종속 항목들이 포함되어 있다. Docker 이미지를 실행하는 경우, 이는 컨테이너의 하나의 인스턴스(또는 다수의 인스턴스)가 된다.

FROM ubuntu:18.04

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get -y install apache2 software-properties-common

# php 다운
RUN LC_ALL=C.UTF-8 add-apt-repository ppa:ondrej/php    

RUN apt-get update && apt-get install -y libapache2-mod-php7.0 php7.0 php7.0-cli php7.0-mysql

RUN a2enmod rewrite

# 80포트 열어줌
EXPOSE 80

# apache 실행
CMD apachectl -D FOREGROUND

2-4. 'docker-compose.yml' 파일 작성

: 이미지 간 연동을 위한 파일

🌱 Docker Compose

모두가 동일한 호스트에 상주하는 다중 컨테이너의 프로세스에서 애플리케이션을 빌드하는 경우, Docker Compose를 사용하여 애플리케이션의 아키텍처를 관리할 수 있다.
Docker Compose는 애플리케이션에 포함되는 서비스를 지정하는 YAML 파일을 작성할 수 있으며, 단일 명령으로 컨테이너를 배치하고 실행할 수 있다. 또한 Docker Compose를 사용하여 스토리지의 지속적 볼륨을 정의하고, 기본 노드를 지정하며, 서비스 종속 항목을 문서화하고 구성할 수도 있다.

version: '3.3'

services:
  webserver:
    build: .
    ports:
      - "9001:80"
    links:
      - mysql:mysql
    restart: always
    volumes:
      - ./html:/var/www/html/
    depends_on:
      - mysql

  mysql:
    image: mysql:5.7
    platform: linux/arm64
    environment:
      MYSQL_ROOT_PASSWORD: '?'
      MYSQL_USER: '?'
      MYSQL_PASSWORD: '?'
      MYSQL_DATABASE: '?'
    volumes:
      - ./data:/var/lib/mysql
    ports:
      - "52000:3306"

2-5. 터미널에서 'docker-compose build' 실행

2-6. 'docker-compose up -d' 실행

2-7. Docker Desktop 이동

Docker Desktop으로 이동해서 잘 실행되었는지 확인해 보았다.

처음에는 mysql:5.7 부분의 stated가 exited로 떠서 엥? 뭐지? 하고 눌러봤더니 mysql의 username이 root로 등록될 수 없다는 것이였다. 그래서 mysql에 접속하여 새로운 user와 비밀번호를 생성하고 위 2-6과 2-7을 재실행 하였더니 위와 같이 잘 연결된 것을 확인할 수 있었다 :)

2-8. localhost:9001 접속

2-9. index.php

VS Code로 돌아오면 data, html 폴더가 생성돼 있는 것을 발견할 수 있다!

html 폴더에 index.php 파일을 생성해 보자.
먼저, php가 잘 동작하는지 알아보기 위해서

<?php
phpinfo();
?>

위 코드를 작성 후 저장한 다음, localhost:9001을 다시 접속했을 때 아래와 같은 화면이 나오면 php가 잘 설치된 것이다 ㅎ.ㅎ

2-10. VSCode extension 활용

VSCode의 확장 기능을 사용해 보자.
mysql을 검색하면 아래와 같은 결과가 나오는데 맨 위에 있는 2개를 설치하였다.

설치가 완료되면 왼쪽 메뉴에 Database, NoSQL이라는 메뉴가 생긴다!
Database 메뉴로 이동하여, 'Create Connection'버튼을 클릭하자.

설정해 놓은 Port No.(52000), Username, Password, Database 등의 정보를 기입하였다.


table이 아무것도 없기 때문에 임의로 board라는 table을 만들어 보았다.

table에 데이터를 임의로 만들 수 있는 기능이 있는데 table 이름에 우클릭을 하고, 'Generate Mock Data'를 누르면 원하는 개수만큼 데이터를 임의로 만들 수 있다.

데이터 개수(mockCount)를 지정한 후, 아래 캡쳐와 같이 우측 상단에 있는 'State Generate' 버튼을 누르면 된다!

짜잔~ 100개의 데이터가 생성된 것을 볼 수 있다

지금까지 간단한 서버 환경 구축이 완료되었다..!

3.

3-1. index.php 재작성

아까 작성했던 index.php 파일을 지우고 아래 코드를 작성하였다.

<?php 

$mysql_hostname = 'host.docker.internal';

$mysql_username = '   ';

$mysql_password = '    ';

$mysql_database = '    ';

$mysql_port = '52000';

$mysql_charset = 'UTF8';

 

$conn = new mysqli($mysql_hostname, $mysql_username, $mysql_password, $mysql_database, $mysql_port, $mysql_charset);

 

if ($conn->connect_errno) {

    echo '[연결실패..] : '.$connect->connect_error.'';

} else {

    echo '[연결성공!]'.'<br>';


}


 

?>

저장 후에, localhost 접속하면 연결이 성공된 것을 볼 수 있다!

연결이 잘 되는 것을 확인했으니 else 문에 있는 코드를 지우고, 파일 이름을 db_setup.php로 바꾸었다.

3-2. query.php

같은 html 폴더에 query.php 파일을 생성하였다.

<?php
include('db_setup.php');

$results = array();

$result = $conn->query("SELECT * FROM board");
while ($row = $result->fetch_array(MYSQLI_ASSOC)) { // 컬러명을 key로 사용
  $results[] = $row;
}
header('Content-type: application/json');
echo json_encode($results, JSON_NUMERIC_CHECK); // 숫자를 숫자로 자동 변환

$conn->close();
?>

파일 저장 후, localhost:9001/query.php를 접속해 보자.

아까 추가해 둔 100개의 데이터를 확인할 수 있다!

3-3. insert.php 작성

<?php
include('db_setup.php');

$content = $_GET[content];

$sql = "INSERT INTO board (update_time, content) VALUES (CURRENT_TIMESTAMP(), '$content')";

if ($conn->query($sql) === TRUE) {
  echo 'Insert New Record';
} else {
  echo $conn->error;
}

$conn->close();
?>

위 코드를 작성한 후, localhost:9001/insert.php에 parameter로 content를 넘겨주면,


insert가 성공된 것을 볼 수 있고,
다시 query.php에 접속하면,

101번째 새로운 데이터가 추가된 것을 볼 수 있다 ㅎㅎ

3-4. update.php 작성

<?php
include('db_setup.php');

$id = $_GET[id];
$content = $_GET[content];

$sql = "UPDATE board SET content='$content', update_time=CURRENT_TIMESTAMP WHERE id=$id";

if ($conn->query($sql)) {
  header("HTTP/1.1 200 OK");
  echo 'Updated';
} else {
  header("HTTP/1.1 400 Not Found");
  echo $conn->error;
}

$conn->close();
?>

insert와 마찬가지로, update.php의 paratmer로 idcontent를 넘겨주었다.

query.php에서 업데이트된 것을 확인할 수 있당

3-5. delete.php 작성

<?php
include('db_setup.php');

$id = $_GET[id];

$sql = "DELETE FROM board WHERE id=$id";

if ($conn->query($sql)) {
  echo 'Deleted';
} else {
  header("HTTP/1.1 404 Not Found");
  echo $conn->error;
}

$conn->close();
?>

이제는 delete.php의 paratemeter로 삭제할 id를 넘겨주면 아래와 같이 Deleted가 보이고, query.php를 접속하면 101번 id가 사라짐을 확인할 수 있다!

4. Flutter 프로젝트 작성

4-1. pubspec.yaml 세팅

dependencies 하위

  • http
  • freezed_annotation
  • provider
  • json_annotation

dev_dependencies 하위

  • freezed
  • json_serializable
  • build_runner

위 패키지들의 최신 버전 pubspec.yaml에 추가한 후,
pub get을 실행한다.

freezed란?

데이터 클래스에서 필요한 기능들을 Code Generation으로 제공해주는 라이브러리다.
json_serializable, copy, toString override, assert 등 편리한 기능들을 제공해준다.
freezed 추가설명

4-2. lib/domain/model

디렉토리 생성 후에, 예시로 post.dart라는 파일을 만들어 보았다.
model의 구조는 아까 만든 구조와 동일하다!

import 'package:json_annotation/json_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'post.freezed.dart';

part 'post.g.dart';

@freezed
class Post with _$Post {
  factory Post({
    required int id,
    @JsonKey(name: 'update_time') required String updateTime,
    required String content,

}) = _Post;

  factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}

위 코드를 작성하고 나면 빨간줄 에러가 발생하는데 우선은 무시하고,
안드로이드 스튜디오 터미널을 열어서
flutter pub run build_runner build
이 코드를 실행하면 에러가 사라질 것이다!

4-2. lib/data/source/remote/board_api.dart

board의 기능을 사용하는 api를 작성해 보자.

http client를 사용하므로 import 해준다.

import 'package:http/http.dart' as http;

class BoardApi {
  final http.Client _client;

  BoardApi({http.Client? client}) : _client = (client ?? http.Client());


  // 에뮬레이터 ip: 10.0.2.2
  // 실제 기기 -> 실제 pc의 ip
  static const baseUrl = 'http://10.0.2.2:9001';

  Future<http.Response> query() async {
    final response = await _client.get(Uri.parse('$baseUrl/query.php'));
    return response;
  }

  Future<http.Response> insert(String content) async {
    final response =
    await _client.get(Uri.parse('$baseUrl/insert.php?content=$content'));
    return response;
  }

  Future<http.Response> update(int id, String content) async {
    final response = await _client
        .get(Uri.parse('$baseUrl/update.php?id=$id&content=$content'));
    return response;
  }

  Future<http.Response> delete(int id) async {
    final response = await _client.get(Uri.parse('$baseUrl/delete.php?id=$id'));
    return response;
  }
}

4-3. lib/domain/repository/board_repository.dart

여기서는 abstract class로 기능을 정의한다.

import 'package:voskat/domain/model/post.dart';

abstract class BoardRepository {
  Future<List<Post>> getPosts();

  Future add(String content);

  Future update(int id, String content);

  Future remove(int id);
}

4-4. lib/data/repository/board_repository_impl.dart

이제 위에서 정의한 기능을 구현한다.

import 'dart:convert';

import 'package:voskat/data/source/remote/board_api.dart';
import 'package:voskat/domain/model/post.dart';
import 'package:voskat/domain/repository/board_repository.dart';

class BoardRepositoryImpl implements BoardRepository {
  BoardApi api;

  BoardRepositoryImpl(this.api);

  @override
  Future add(String content) async {
    await api.insert(content);
  }

  @override
  Future<List<Post>> getPosts() async {
    final response = await api.query();
    final Iterable json = jsonDecode(response.body);
    return json.map((e) => Post.fromJson(e)).toList();
  }

  @override
  Future remove(int id) async {
    await api.delete(id);
  }

  @override
  Future update(int id, String content) async {
    await api.update(id, content);
  }
}

4-5. lib/presentation/

이 디렉토리에 아래 4개의 파일을 만들고, main.dart를 수정한다.
home_screen.dart
home_view_model.dart
home_state.dart
home_event.dart

home_state.dart

import 'package:voskat/domain/model/post.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_state.freezed.dart';
part 'home_state.g.dart';

@freezed
class HomeState with _$HomeState {
  factory HomeState({
    @Default([]) List<Post> posts,
    @Default(false) bool isLoading,
  }) = _HomeState;

  factory HomeState.fromJson(Map<String, dynamic> json) => _$HomeStateFromJson(json);
}

코드 작성 후에 build_runner를 실행해 준다. (이번에도 마찬가지로 빨간줄 에러 무시하세욧)

home_event.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_event.freezed.dart';

@freezed
abstract class HomeEvent<T> with _$HomeEvent<T> {
  const factory HomeEvent.query() = Query;
  const factory HomeEvent.insert(String content) = Insert;
  const factory HomeEvent.update(int id, String content) = Update;
  const factory HomeEvent.delete(int id) = Delete;
}

이것두 코드 작성 후에 build_runner를 실행해 준다.

home_view_model.dart

import 'package:voskat/domain/repository/board_repository.dart';
import 'package:voskat/presentation/home_event.dart';
import 'package:voskat/presentation/home_state.dart';
import 'package:flutter/material.dart';

class HomeViewModel with ChangeNotifier {
  final BoardRepository _repository;

  var _state = HomeState();

  HomeState get state => _state;

  HomeViewModel(this._repository) {
    // 처음에 데이터 가져옴
    _getPosts();
  }

  void onEvent(HomeEvent event) {
   // freezed의 패턴 매칭 사용
    event.when(
      query: _getPosts,
      insert: _insert,
      update: _update,
      delete: _delete,
    );
  }

  Future _delete(int id) async {
    await _repository.remove(id);
    await _getPosts();
  }

  Future _update(int id, String content) async {
    await _repository.update(id, content);
    await _getPosts();
  }

  Future _insert(String content) async {
    await _repository.add(content);
    await _getPosts();
  }

  Future _getPosts() async {
    // 로딩
    _state = state.copyWith(isLoading: true);
    notifyListeners();

    // 데이터 가져옴
    final result = await _repository.getPosts()
      ..sort((a, b) => -a.id.compareTo(b.id));

    _state = state.copyWith(
      isLoading: false,
      posts: result,
    );
    notifyListeners();
  }
}

home_screen.dart

import 'package:voskat/domain/model/post.dart';
import 'package:voskat/presentation/home_event.dart';
import 'package:voskat/presentation/home_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // provider 이용
    final viewModel = context.watch<HomeViewModel>();
    final state = viewModel.state;

    return Scaffold(
      appBar: AppBar(
        title: const Text(''),
      ),
      body: ListView.builder(
        itemCount: state.posts.length,
        itemBuilder: (context, index) {
          final post = state.posts[index];
          return GestureDetector(
            onTap: () async {
              _controller.text = post.content;
              bool? result = await showDialog<bool>(
                context: context,
                builder: (_) =>
                    _buildUpdateDeleteAlertDialog(viewModel, post, context),
              );

              if (result != null) {}
            },
            child: ListTile(
              title: Text('${post.id} : ${post.content}'),
              subtitle: Text(post.updateTime),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          showDialog(
              context: context,
              builder: (_) => _buildInsertAlertDialog(viewModel, context));
        },
      ),
    );
  }

  AlertDialog _buildInsertAlertDialog(
      HomeViewModel viewModel,
      BuildContext context,
      ) {
    _controller.text = '';
    return AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: _controller,
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () {
            viewModel.onEvent(HomeEvent.insert(_controller.text));
            Navigator.pop(context, true);
          },
          child: const Text('Insert'),
        ),
      ],
    );
  }

  AlertDialog _buildUpdateDeleteAlertDialog(
      HomeViewModel viewModel,
      Post post,
      BuildContext context,
      ) {
    return AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: _controller,
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () {
            viewModel.onEvent(HomeEvent.delete(post.id));
            Navigator.pop(context, true);
          },
          child: const Text('Delete'),
        ),
        TextButton(
          onPressed: () {
            viewModel.onEvent(HomeEvent.update(post.id, _controller.text));
            Navigator.pop(context, true);
          },
          child: const Text('Update'),
        ),
      ],
    );
  }
}

main.dart

import 'package:board/data/repository/board_repository_impl.dart';
import 'package:board/data/source/remote/board_api.dart';
import 'package:board/presentation/home_screen.dart';
import 'package:board/presentation/home_view_model.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider.value(
      value: HomeViewModel(BoardRepositoryImpl(api: BoardApi())),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: HomeScreen(),
    );
  }
}

4-6. 코드 실행

에뮬레이터를 켜서 실행해보면 요롷게 뜬당!

profile
👩🏻‍💻 iOS Developer

0개의 댓글