はじめに
普段はWebエンジニアをしているのですが、
ネイティブアプリ化を想定しているNext製WebアプリがありXcodeに手を染めました。
その時の備忘についてまとめたのが今回の内容です。
完成系
- DBに登録されているデータをAPIで受け取って表示します
- ボトムバーのナビゲーションを利用してページ切り替えができます
- 画面上部のソートと検索ボタンは飾りです
バックエンド(FastAPI)
Dockerを使用して環境構築します。
FastAPIの経験がある方は、ざっとモデルを確認して合わせていただくだけで大丈夫です。
プロジェクトディレクトリ作成
mkdir serverside
以降、serversideディレクトリをルートしてファイルのパスを記載します。
ファイルだけでなく、無いディレクトリは都度作成してください。
Docker
FROM python:3.11-slim
RUN apt-get update \
&& apt-get -y install gcc libmariadb-dev \
&& apt-get clean
ENV TZ="Asia/Tokyo"
RUN echo $TZ > /etc/timezone
WORKDIR /workspace
COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
[mysqld]
default-time-zone='Asia/Tokyo'
alembic==1.11.1
fastapi==0.99.1
mysqlclient==2.1.1
pydantic==1.10.11
python-dotenv==1.0.0
SQLAlchemy==2.0.18
uvicorn==0.22.0
version: "3.8"
services:
db:
image: mariadb:latest
container_name: mariadb
restart: always
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: test_database
MYSQL_USER: test_user
MYSQL_PASSWORD: password
ports:
- 3306:3306
volumes:
- db_data:/var/lib/mysql
- ./docker/db/conf.d:/etc/mysql/conf.d
networks:
- backend
fastapi:
build:
context: ./docker/api
dockerfile: Dockerfile.dev
container_name: fastapi
environment:
PYTHONPATH: /workspace/app
volumes:
- ./app:/workspace/app
- ./scripts:/workspace/scripts
- ./.env:/workspace/.env
working_dir: /workspace
command: bash -c "./scripts/run.sh"
ports:
- 8000:8000
depends_on:
- db
networks:
- backend
networks:
backend:
volumes:
db_data:
起動用スクリプト
#!/bin/bash
cd /workspace/app/db
alembic upgrade head
cd /workspace
uvicorn app.main:app --reload --port=8000 --host=0.0.0.0
環境変数
次に環境変数を設定します。
import os
from dotenv import load_dotenv
dotenv_path = os.path.join(os.path.dirname(__file__), '../.env')
load_dotenv(dotenv_path)
DB_USER = os.getenv('MYSQL_USER')
DB_PASSWORD = os.getenv('MYSQL_PASSWORD')
DB_ROOT_PASSWORD = os.getenv('MYSQL_ROOT_PASSWORD')
DB_HOST = os.getenv('MYSQL_HOST')
DB_NAME = os.getenv('MYSQL_DATABASE')
MYSQL_ROOT_PASSWORD=root_password
MYSQL_DATABASE=test_db
MYSQL_USER=test_user
MYSQL_PASSWORD=password
MYSQL_HOST=db
DB作成
DB接続に使用する関数などを作成します。
from sqlalchemy.engine import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from env import DB_HOST, DB_NAME, DB_PASSWORD, DB_USER
DATABASE = "mysql://%s:%s@%s/%s?charset=utf8mb4" % (
DB_USER,
DB_PASSWORD,
DB_HOST,
DB_NAME,
)
engine = create_engine(
DATABASE,
echo=True,
pool_size=20,
max_overflow=100
)
Base = declarative_base()
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)
ScopedSessionLocal = scoped_session(SessionLocal)
def get_db():
db = SessionLocal()
try:
yield db
db.commit()
except Exception as e:
print(e)
db.rollback()
finally:
db.close()
モデルを作成します。
from database import Base
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .mixins import TimestampMixin
class TestItems(Base, TimestampMixin):
__tablename__ = "test_items"
__table_args__ = {"comment": "テストアイテム"}
id = Column(Integer, autoincrement=True, primary_key=True, comment="テストアイテムID")
field1 = Column("field1", String(length=20), nullable=False, comment="field1")
tield2 = Column("tield2", String(length=20), nullable=False, comment="tield2")
from sqlalchemy import Column, text
from sqlalchemy.dialects.mysql import TIMESTAMP as Timestamp
class TimestampMixin(object):
created_at = Column(
Timestamp,
nullable=False,
server_default=text("current_timestamp"),
comment="作成日時",
)
updated_at = Column(
Timestamp,
nullable=False,
server_default=text("current_timestamp on update current_timestamp"),
comment="更新日時",
)
alembicの初期化と設定をします。
docker-compose build
docker-compose up -d
docker exec -it fastapi bash
/workspace# cd app/db
/workspace/app/db# alembic init migrations
「追加」とコメントアウトがある箇所だけ追加します。
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from database import Base # 追加
from env import DB_HOST, DB_NAME, DB_PASSWORD, DB_USER # 追加
import models.test_items # 追加
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
DATABASE = "mysql://%s:%s@%s/%s?charset=utf8mb4" % ( # 追加
DB_USER,
DB_PASSWORD,
DB_HOST,
DB_NAME,
)
config.set_main_option('sqlalchemy.url', DATABASE)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata # 追加
# other values from the config, defined by the needs of env.py,
...以下省略
以下をコメントアウトします。
# sqlalchemy.url = driver://user:pass@localhost/dbname
マイグレーションファイルを作成・マイグレート実行します。
/workspace/app/db# alembic revision --autogenerate -m "first_migration"
/workspace/app/db# alembic upgrade head
エンドポイント~ロジック作成
メインスクリプトでルーティングとCORS設定(本番で["*"]はNGです。)
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers.test_items import router as test_item_router
app = FastAPI()
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
router = APIRouter()
router.include_router(
test_item_router,
prefix='/api/v1/test',
tags=['test_items']
)
app.include_router(router)
入出力の型定義
from typing import List
from pydantic import BaseModel
class TestItemResponseItem(BaseModel):
id: int | None
field1: str
field2: str
class Config:
orm_mode = True
schema_extra = {
"example": {
"id" : "1",
"field1": "hogehoge",
"field2": "fugafuga",
},
}
class TestItemResponse(BaseModel):
results: List[TestItemResponseItem]
class Config:
orm_mode = True
schema_extra = {
"example": {
"results" : [TestItemResponseItem.Config.schema_extra["example"]]
},
}
class TestItemCreate(BaseModel):
field1: str
field2: str
class Config:
schema_extra = {
"example": {
"field1": "hogehoge",
"field2": "fugafuga",
},
}
ルーター
from typing import List
from cruds import test_items as crud
from database import get_db
from fastapi import APIRouter, Depends
from schemas.test_items import TestItemResponse, TestItemCreate, TestItemResponseItem
from sqlalchemy.orm import Session
router = APIRouter()
@router.get('', response_model=TestItemResponse)
async def read_test_items(db: Session = Depends(get_db)):
return crud.read_test_items(db=db)
@router.post('', response_model=TestItemResponseItem)
async def create_test_item(test_item: TestItemCreate, db: Session = Depends(get_db)):
return crud.create_test_item(test_item=test_item, db=db)
ロジック
from typing import List
from models.test_items import TestItem
from schemas.test_items import TestItemCreate
from sqlalchemy.orm import Session
def read_test_items(db: Session):
"""全テストアイテムを取得する
Args:
db (Session): データベース接続のセッションオブジェクト
Returns:
(List[TestItem]): テストアイテムオブジェクトのリスト
"""
test_items = db.query(TestItem).all()
return {
"results": test_items
}
def create_test_item(db: Session, test_item: TestItemCreate) -> TestItem:
"""テストアイテムを作成する
Args:
db (Session): データベース接続のセッションオブジェクト
test_item (TestItemCreate): テストアイテム作成スキーマ
Returns:
new_test_item (TestItem): 作成されたテストアイテムオブジェクト
"""
new_test_item = TestItem(
field1=test_item.field1,
field2=test_item.field2
)
db.add(new_test_item)
# db.refresh(new_test_item)
return new_test_item
表示する用のデータ作成
FastAPI標準のSwaggerからデータをPOSTして作成します。
なお、シーダーの作り方については別途記事で紹介する予定です。
以下をブラウザで表示します。
http://localhost:8000/docs
POSTメソッドの「Try it out」を選択し「Execute」を実行します。
するとexampleのデータがDBに登録されます。
試しにGETメソッドを実行して登録したデータを表示してみてください。
バックエンドの構築は以上となります。
最低限の入出力はできているため、あとはご自由にカスタマイズしてみてください。
画面(SwiftUI)
Xcodeはインストール済みとします。
プロジェクト作成
「Create a new Xcode project」を選択します。
「Product Name」「Organization Identifier」を入力し、Nextに進みます。
あとは保存したいディレクトリを指定すればプロジェクトの雛形が出来上がります。
全体コード
import SwiftUI
enum ContentState {
case first
case second
case third
}
struct Response: Codable {
var results: [Result]
}
struct Result: Codable {
var id: Int
var field1: String?
var field2: String?
}
struct FirstView: View {
@Binding var state: ContentState
@Binding var results: [Result]
var body: some View {
List(results, id: \.id) { item in
VStack(alignment: .leading, spacing: 10) {
Text("No: \(String(item.id))").font(.headline).fontWeight(.bold).foregroundColor(.orange)
Text("field1: \(item.field1 ?? "error")")
Text("field2: \(item.field2 ?? "error")")
}
}
.navigationTitle("ページ1")
}
}
struct SecondView: View {
@Binding var state: ContentState
@Binding var results: [Result]
var body: some View {
List(results, id: \.id) { item in
VStack(alignment: .leading, spacing: 10) {
Text("No: \(String(item.id))").font(.headline).fontWeight(.bold).foregroundColor(.orange)
Text("field1: \(item.field1 ?? "error")")
Text("field2: \(item.field2 ?? "error")")
}
}
.navigationTitle("ページ2")
}
}
struct ThirdView: View {
@Binding var state: ContentState
@Binding var results: [Result]
var body: some View {
Text("hogehoge")
.navigationTitle("ThirdView")
}
}
struct ContentView: View {
@State var state: ContentState = .first
@State var results = [Result]()
var body: some View {
NavigationView {
ZStack {
switch state {
case .first:
FirstView(
state: $state,
results: $results
)
case .second:
SecondView(
state: $state,
results: $results
)
case .third:
ThirdView(
state: $state,
results: $results
)
}
}
.toolbar {
if state == .first || state == .second {
/// ナビゲーションバー左
ToolbarItem(placement: .navigationBarLeading){
Button(action: {}) {
Image(systemName: "arrow.up.arrow.down")
Text("Sort")
}
}
/// ナビゲーションバー右
ToolbarItem(placement: .navigationBarTrailing){
Button(action: {}) {
Image(systemName: "magnifyingglass")
Text("Search")
}
}
}
/// ボトムバー
ToolbarItemGroup(placement: .bottomBar) {
Button(action: {
state = .first
}) {
VStack(alignment: .center, spacing: 0) {
Image(systemName: "circle.circle.fill")
Text("TOP")
}
}
Spacer()
Button(action: {
state = .second
}) {
VStack(alignment: .center, spacing: 0) {
Image(systemName: "circle.circle")
Text("SUB")
}
}
Spacer()
Button(action: {
state = .third
}) {
VStack(alignment: .center, spacing: 0) {
Image(systemName: "person.circle")
Text("Account")
}
}
}
}
}.onAppear(perform: loadData)
}
func loadData() {
guard let url = URL(string: "http://localhost:8000/api/v1/test_items") else {
print("Invalid URL error")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
let decoder = JSONDecoder()
guard let decodedResponse = try? decoder.decode(Response.self, from: data) else {
print("Json decode error")
return
}
DispatchQueue.main.async {
results = decodedResponse.results
}
} else {
print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
}
}.resume()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
先ほどSwaggerで登録したデータが表示されれば成功です。
ページの切り替えについて
構造体ContentViewで宣言しているボトムバーには3つのボタンがあり、それぞれにactionが設定されています。
/// ボトムバー
..
ToolbarItemGroup(placement: .bottomBar) {
Button(action: {
state = .first
}) {
..
ボタンをタップするとページ名[state]が更新されます。
列挙型ContentStateで定義したページ名を、switch文で表示切り替えを行なっています。
enum ContentState {
case first
case second
case third
}
..
..
struct ContentView: View {
@State var state: ContentState = .first
@State var results = [Result]()
var body: some View {
NavigationView {
ZStack {
switch state {
case .first:
FirstView(
state: $state,
results: $results
)
case .second:
SecondView(
state: $state,
results: $results
)
case .third:
ThirdView(
state: $state,
results: $results
)
}
}
.toolbar {
..
それぞれのページのビューは3つの構造体First~ThirdViewで定義しています。
※今回は練習用なので、FirstViewとSecondViewの内容は同じで、ThirdViewは非常に簡素な内容になっています。
HTTP通信について
サーバサイドへのリクエストはContentViewのloadDataメソッドで行われます。
func loadData() {
guard let url = URL(string: "http://localhost:8000/api/v1/test_items") else {
print("Invalid URL error")
return
}
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
let decoder = JSONDecoder()
guard let decodedResponse = try? decoder.decode(Response.self, from: data) else {
print("Json decode error")
return
}
DispatchQueue.main.async {
results = decodedResponse.results
}
} else {
print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
}
}.resume()
}
NavigationViewにメソッドチェーンで定義されたonAppear(perform: loadData)
によりloadData関数がページがロードされるたびに実行されます。
なお、レスポンスのresultsキーの配列要素の型は構造体Codableで定義しています。
struct Result: Codable {
var id: Int
var field1: String?
var field2: String?
}
最後に
今回の内容は以上となります。
ご拝読いただきありがとうございました。
参考